diff --git a/src/editor-shared/Hotbar.jsx b/src/editor-shared/Hotbar.jsx index 3e1cfa2..1b313de 100644 --- a/src/editor-shared/Hotbar.jsx +++ b/src/editor-shared/Hotbar.jsx @@ -16,6 +16,13 @@ import Icon from './Icon'; function Hotbar({ visible, slots = [], activeIndex = 0, onSelect, mobileMode = false }) { if (!visible) return null; + // ГЛОБАЛЬНОЕ ПРАВИЛО (для всех игр тулбокса): если в инвентаре нет ни + // одного предмета — панель инвентаря НЕ показываем вовсе. Пустой hotbar + // из 5 серых ячеек загромождает экран в играх, где инвентарь не нужен. + // Панель появится автоматически, как только в слот попадёт предмет. + const hasAnyItem = Array.isArray(slots) && slots.some((s) => s != null); + if (!hasAnyItem) return null; + const SLOT_COUNT = 5; const cells = []; for (let i = 0; i < SLOT_COUNT; i++) { diff --git a/src/engine/BabylonScene.js b/src/engine/BabylonScene.js index 2c5acb6..abd7686 100644 --- a/src/engine/BabylonScene.js +++ b/src/engine/BabylonScene.js @@ -1,7706 +1,7729 @@ -/** - * BabylonScene — обёртка над Babylon.js Engine + Scene с Roblox-style навигацией. - * - * Управление камерой (как в Roblox Studio): - * - ПКМ + drag : повернуть камеру (yaw/pitch вокруг своей оси) - * - ПКМ + WASD : полёт (вперёд/назад/влево/вправо относительно взгляда) - * - ПКМ + Q/E : вниз/вверх по миру - * - ПКМ + Shift : ускоренный полёт (×2.5) - * - Колесо : zoom (приближение по оси взгляда) - * - Средняя кнопка drag : pan (сдвиг параллельно экрану) - * - F : фокус на (0,0,0) — будет на выбранный объект позже - * - * Используем UniversalCamera + ручной обработчик мыши/клавиш для точной - * имитации Roblox-controls (стандартные attachControl делают не то что нужно). - * - * Этап 1, неделя 1: только сцена, камера и пол с сеткой. Блоки и физика — позже. - */ -import { - Engine, - Scene, - UniversalCamera, - Vector3, - Color3, - Color4, - HemisphericLight, - DirectionalLight, - ShadowGenerator, - CascadedShadowGenerator, - SSAORenderingPipeline, - MeshBuilder, - StandardMaterial, - DynamicTexture, - UtilityLayerRenderer, - TransformNode, - ParticleSystem, - Texture, - Ray, - Tools as BabylonTools, -} from '@babylonjs/core'; -import { BlockManager } from './BlockManager'; -import { TerrainManager, VOXEL_SIZE as TERRAIN_VOXEL_SIZE, TERRAIN_MATERIALS as TERRAIN_MATERIAL_DEFS } from './TerrainManager'; -// Этап 1 voxel-движка: новые классы chunks-based архитектуры (см. -// RUBLOX_VOXEL_ENGINE_PLAN.md). Пока работают параллельно с legacy -// TerrainManager как shadow-копия — для замеров статистики чанков и -// готовности к Этапу 2 (greedy meshing). -import { VoxelWorld } from './voxel/VoxelWorld'; -import { VoxelRenderer } from './voxel/VoxelRenderer'; -import { WorldGenerator, DEFAULT_GENERATOR_PARAMS } from './voxel/WorldGenerator'; -// Этап 6: deco-слой 0.05м — мелкие воксельные декорации (цветы/грибы/трава). -import { DecoManager } from './DecoManager'; -import { GRASS_MODELS_POOL } from './DecoModels'; -import { placeVoxelTree, TREE_TYPES } from './VoxelTreeBuilder'; -import { UserModelManager, parseUserModelId, USER_MODEL_PREFIX } from './UserModelManager'; -import { ModelManager } from './ModelManager'; -import { PrimitiveManager } from './PrimitiveManager'; -import { BillboardUiManager } from './BillboardUiManager'; -import { getPrimitiveType } from './PrimitiveTypes'; -import { FolderManager } from './FolderManager'; -import { GuiManager } from './GuiManager'; -import { ModalManager } from './ModalManager'; -import { InventoryManager } from './InventoryManager'; -import { WeaponSystem } from './WeaponSystem'; -import { ZombieManager } from './ZombieManager'; -import { NpcManager } from './NpcManager'; -import { ConstraintManager } from './ConstraintManager'; -import { BeamManager } from './BeamManager'; -import { ZombieSpawnerManager } from './ZombieSpawnerManager'; -import { DynamicsManager } from './DynamicsManager'; -import { Environment } from './Environment'; -import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager'; -import { GameAudioManager } from './GameAudioManager'; -import { AssetManager } from './AssetManager'; -import { SoundLibrary } from './SoundLibrary'; -import { SoundManager } from './SoundManager'; -import { GlbLibrary } from './GlbLibrary'; -import { GdLevelManager } from './GdLevelManager'; -import { GdSkybox } from './GdSkybox'; -import { GdGroundSkin } from './GdGroundSkin'; -import { GdSpikes } from './GdSpikes'; -import { GdStartArch } from './GdStartArch'; -import { GdPortalArch } from './GdPortalArch'; -import { GdDiamond } from './GdDiamond'; -import { GdPlayerModeSkin } from './GdPlayerModeSkin'; -import { GdFinish } from './GdFinish'; -import { GdForest } from './GdForest'; -import { GdPlayerCube } from './GdPlayerCube'; -import { GdPlayerTrail } from './GdPlayerTrail'; -import { GdPostFx } from './GdPostFx'; -import { PhysicsAABB } from './PhysicsAABB'; -import { PlayerController } from './PlayerController'; -import { SelectionManager } from './SelectionManager'; -import { GizmoController } from './GizmoController'; -import { HistoryManager } from './HistoryManager'; -import { GameRuntime } from './GameRuntime'; -import { attachConsoleHook, devlogReset } from './devlog'; -import { TerrainMesh, CHUNK_SIZE as TERRAIN_MESH_CHUNK } from './terrain/TerrainMesh'; -import { VoxelGrid } from './terrain/VoxelGrid'; -import { RobloxTerrain, CHUNK_SIZE as ROBLOX_CHUNK_SIZE } from './robloxterrain/RobloxTerrain'; -import { DensityGrid as RobloxDensityGrid, CELL_SIZE as ROBLOX_CELL_SIZE } from './robloxterrain/DensityGrid'; -import { SmoothDecoManager } from './robloxterrain/SmoothDecoManager'; - -export class BabylonScene { - /** - * @param {HTMLCanvasElement} canvas — DOM-элемент для рендера - */ - constructor(canvas) { - // DevLog: на localhost подключаем перехват console.* для записи в файл - // на твоей машине (c:\...\dev-tools\devlog.txt). Это даёт Claude - // возможность читать свежие логи без копипасты вручную. - try { - devlogReset(); - attachConsoleHook(); - } catch (e) {} - this.canvas = canvas; - this.engine = null; - this.scene = null; - this.camera = null; - - // Состояние ввода. Храним КОДЫ клавиш (e.code), не key — чтобы - // работало на русской раскладке: KeyW не зависит от языка ввода. - this._codes = new Set(); - this._shiftDown = false; - this._isRotating = false; // ПКМ зажата → крутим камеру - this._isPanning = false; // СКМ зажата → pan - this._lastMouseX = 0; - this._lastMouseY = 0; - - // Параметры - this.MOVE_SPEED = 12; // юнитов/секунду при WASD - this.SHIFT_MULTIPLIER = 2.5; - this.ROTATE_SENSITIVITY = 0.0035; // радиан/пиксель - this.ZOOM_SPEED = 1.5; - this.PAN_SENSITIVITY = 0.025; - - // Состояние редактора блоков - this.blockManager = null; - this.modelManager = null; - this.primitiveManager = null; - this.folderManager = null; - this.guiManager = null; // 2D-UI слой (Frame/Text/Button/Image) - this.inventory = null; // инвентарь игрока (9 слотов hot-bar) - this.weapons = null; // система оружия (создаётся при enterPlayMode) - this.zombieManager = null; // AI зомби (создаётся при enterPlayMode) - this.npcManager = null; // управляемые скриптом NPC (Фаза 4.1) - this.constraintManager = null; // связи объектов (Фаза 5, Constraints) - this.beamManager = null; // лучи и следы (Фаза 5.2) - this.spawnerManager = null; // спавнеры зомби - this.environment = null; - this.audioManager = null; - this.assetManager = null; // библиотека пользовательских картинок - this.soundLibrary = null; // библиотека пользовательских звуков (Фаза 5.5) - this.soundManager = null; // 3D-воспроизведение звука (Play-only) - this.glbLibrary = null; // импортированные .glb-модели (Фаза 5.8) - this.selection = null; // SelectionManager - // Тач-режим (мобилки/планшеты) — выставляется снаружи через - // setTouchMode() ДО enterPlayMode. Влияет на PlayerController. - this._touchMode = false; - this._activeTool = 'block'; // 'select' | 'block' | 'model' | 'primitive' | 'erase' - this._activeBlockType = 'grass'; - this._activeModelType = null; - this._activePrimitiveType = 'cube'; - this._ghostMesh = null; - this._ghostRotationY = 0; // угол поворота ghost-модели (R = +90°) - this._gizmo = null; - this._gizmoLayer = null; - this._gizmoDragging = false; // флаг что идёт drag гизмо - this._isDragPlacing = false; // флаг drag-постановки/удаления блоков - this._isTerrainBrushing = false; // флаг drag-кисти террейна - this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять - this._lastPlacedKey = null; // последняя клетка чтобы не ставить дважды - this._dragLockAxis = null; // 'y' | 'x' | 'z' — плоскость зафиксированная первым блоком - this._dragLockValue = 0; // значение по фиксированной оси - - // Точка спавна игрока в режиме Play (обновляется setSpawnPoint) - this._spawnPoint = { x: 0, y: 5, z: 0 }; - // Модель персонажа для режима Play. - // Дефолт — R15-скин bacon-hair (классический Roblox-вид). - // 'skin_*' грузится из characters//body.glb (R15-скелет), - // 'character-*' — старые Kenney-модели. - this._playerModelType = 'skin_bacon-hair'; - // Размер пола: пол идёт от -worldHalf до +worldHalf по X и Z. - // По умолчанию 80×80 (worldHalf = 40). Можно менять через setWorldSize(). - this._worldHalf = 40; - // Видимость пола (можно «удалить» — пол исчезнет визуально и из физики) - this._floorEnabled = true; - // Множитель силы прыжка (1 = базовый, 1.5 = в 1.5 раза выше) - this._jumpPowerMul = 1; - // Прицел в Play: 'none' | 'dot' | 'cross' | 'circle'. По умолчанию выключен. - this._crosshair = 'none'; - - // Скрипты пользователя (массив { id, code, target? }). - // На этапе 2.1 — только один глобальный «scene script», без UI редактирования. - // Хранится в проекте через serialize/loadFromState. - this._scripts = []; - this.gameRuntime = null; // GameRuntime создаётся при enterPlayMode - - // Режим Play - this.player = null; // PlayerController когда играем - this.physics = null; // PhysicsAABB - this._editorCameraSnapshot = null; // запоминаем позицию редактор-камеры - this._isPlaying = false; - - // Drag-detection: чтобы не ставить блок при rotate (mouseup без movement - // = клик; с movement = drag). - this._mouseDownTime = 0; - this._mouseDownX = 0; - this._mouseDownY = 0; - this._mouseDownButton = -1; - - // Слушатели — храним чтобы корректно отписаться - this._listeners = []; - this._resizeHandler = null; - } - - init() { - // На тач-устройствах сразу отключаем anti-aliasing — это даёт - // заметный буст FPS на мобилах. Anti-aliasing полезен только на - // больших мониторах с низким DPR. - const isTouchDevice = (typeof window !== 'undefined') && ( - 'ontouchstart' in window || (navigator.maxTouchPoints || 0) > 0 - ); - const isSmallScreen = (typeof window !== 'undefined') - && window.innerWidth <= 1024; - const useAA = !(isTouchDevice && isSmallScreen); - // MOBILE-OPT (этап 1): флаг для всех мобильных оптимизаций - // Можно принудительно отключить через ?desktop=1 в URL (для отладки). - const forceDesktop = (typeof window !== 'undefined') - && new URLSearchParams(window.location.search).has('desktop'); - this._isMobileMode = (isTouchDevice && isSmallScreen) && !forceDesktop; - this.engine = new Engine(this.canvas, useAA, { - preserveDrawingBuffer: true, - stencil: true, - // Parallel shader compile — критично для устранения фризов при - // повороте камеры. Когда новый material попадает во frustum, - // Babylon без этого синхронно компилит shader и блокирует UI. - // С параллельным compile рендер использует fallback shader и - // переключается на оптимизированный когда тот готов. - useHighPrecisionFloats: false, - powerPreference: 'high-performance', - }, true); - // MOBILE-OPT (этап 1.5): hardware scaling ОТКЛЮЧЁН. - // Логи показали что узкое место — CPU (draw calls растут до 60k), - // а не GPU fillrate. Скейлинг ухудшал картинку и не помогал FPS. - - // PERF-METRICS: счётчики для perf-overlay. Накопительно за окно - // сэмплинга 5сек, потом overlay читает и сбрасывает. - this._perfMetrics = { - render_ms_sum: 0, render_count: 0, - physics_ms_sum: 0, physics_count: 0, - script_ms_sum: 0, script_count: 0, - // Замер idle-времени между концом prev-render и началом next-render. - // Если idle ≈ frame_ms - render_ms — значит мы GPU-bound (JS-поток - // ждёт GPU/V-Sync). Если idle мал — CPU-bound (что-то ещё в JS ест). - idle_ms_sum: 0, idle_count: 0, - _lastRenderEnd: 0, - }; - - this.scene = new Scene(this.engine); - this.scene.clearColor = new Color4(0.5, 0.7, 0.9, 1.0); - // ambient: материалы TerrainManager ставят mat.ambientColor=(1,1,1), - // но без scene.ambientColor != 0 это умножается на 0 и боковые грани - // вокселей остаются чёрными (направленный свет не освещает их с - // sunset preset). (0.3,0.3,0.3) даёт мягкое всестороннее освещение, - // не пересвечивает существующие сцены. - this.scene.ambientColor = new Color3(0.3, 0.3, 0.3); - // Глобальный хендл для отладки из консоли: window.__BS — это инстанс - // BabylonScene; window.__SC — Babylon scene; window.__ENG — engine. - // window.__BJS — набор Babylon-классов для dev-инструментов - // (@babylonjs/core модульный, window.BABYLON не существует) — - // им пользуется съёмщик hero-кадров dev-tools/wiki-shots/shoot-hero.js. - if (typeof window !== 'undefined') { - window.__BS = this; - window.__SC = this.scene; - window.__ENG = this.engine; - window.__BJS = { UniversalCamera, Vector3, Tools: BabylonTools }; - } - // ВАЖНО: blockMaterialDirtyMechanism НЕ включаем здесь. - // Когда true — ставим свойства материала (emissiveColor/disableLighting/ - // alpha) у новых мешей (трейсеры выстрелов, debris при смерти, - // муззл-флэш, импакт), но шейдер пересчитывается с дефолтами и эти - // свойства не применяются. Эффект: трейсер/дебрис создаются, но - // НЕ ВИДНЫ. Включать только локально вокруг массовых операций - // (если когда-то появится нужда), сразу выключая обратно. - // Skip pointer-move picking — не делаем raycast от мыши на каждый - // mousemove. Игроку важны клик и hover-через-canvas, а не каждый move. - this.scene.skipPointerMovePicking = true; - // Параллельная компиляция шейдеров — фоновая компиляция новых - // материалов без блокировки рендера (если поддержано WebGL2). - if (this.engine.getCaps?.()?.parallelShaderCompile !== undefined) { - try { - // Babylon 6+ — это просто флаг capability, выставляется - // автоматически при поддержке. Логируем для отладки. - // eslint-disable-next-line no-console - console.log('[BabylonScene] parallel shader compile:', - !!this.engine.getCaps().parallelShaderCompile); - } catch (e) {} - } - - // Возвращаем detachControl — наши mousedown-listeners на canvas с - // capture=true должны работать без вмешательства Babylon-pointerHandler. - // Гизмо запустим вручную через прямые pointerdown/move/up на utility-сцене. - this.scene.detachControl(); - - this._createCamera(); - this._createLights(); - this._createGroundGrid(); - this._createGhostBlock(); - this._createSpawnMarker(); - this._setupInputControls(); - - // Менеджеры объектов - this.blockManager = new BlockManager(this.scene); - // При создании нового proto-меша блока — сразу регистрируем его - // как shadow caster (если генератор уже создан). - this.blockManager.setOnProtoCreated((proto) => { - this.addShadowCaster(proto); - }); - - // Менеджер декораций — Этап 6 voxel-движка. - // Мини-воксели 0.05м для цветов/грибов/травы. Без коллизий. - this.decoManager = new DecoManager(this.scene); - this.decoManager.setOnChange(() => { - if (this._onSceneChange) this._onSceneChange(); - }); - - // Менеджер ландшафта — отдельный voxel-слой 1×1×1, рисуемый кистями. - // Использует thin-instances per материал, как BlockManager. - this.terrainManager = new TerrainManager(this.scene); - // ОПТИМИЗАЦИЯ: НЕ регистрируем terrain как shadow caster. Большая - // карта с 150K voxel'ов в shadow renderList даёт +50-100% нагрузки - // на GPU. Тени от деревьев на земле выглядят не критично, а receiveShadows - // оставлен — тени от других объектов (моделей) показываются. - // this.terrainManager.setOnProtoCreated((proto) => { - // this.addShadowCaster(proto); - // }); - this.terrainManager.setOnChange(() => { - // Пометить сцену как изменённую — автосохранение подхватит. - // Имя коллбэка — _onSceneChange (то же что у blockManager/ - // modelManager/primitiveManager). Раньше тут было _onChange — - // несуществующее поле, из-за чего террейн не сохранялся - // автоматически. Только ручная кнопка «Сохранить» дёргает - // serialize() напрямую и попадала в БД. - if (this._onSceneChange) this._onSceneChange(); - }); - - // === Этап 1 voxel-движка: shadow-копия террейна в новой архитектуре === - // Параллельно с TerrainManager работает VoxelWorld с теми же voxel'ами, - // но в формате chunks 32×32×32. Пока БЕЗ рендера (флаг useVoxelWorld= - // false) — только структура данных для замера chunk-статистики и - // подготовки к Этапу 2. - // window.__voxelWorldStats() — выведет в консоль текущую статистику. - // window.__voxelWorldRender(true/false) — переключит рендер на новый - // (когда будет готов greedy). Сейчас рендерит дублирующиеся mesh'и - // поверх старых — для визуальной валидации. - this.voxelWorld = new VoxelWorld(); - this.voxelWorld.setOnChange(() => { - // Авто-rebuild dirty чанков при изменении (только если рендер включён) - if (this._voxelRenderEnabled && this.voxelRenderer) { - this.voxelRenderer.rebuildDirty(); - } - }); - this.voxelRenderer = new VoxelRenderer(this.voxelWorld, this.scene); - this.voxelRenderer.setOnMeshCreated((m) => this.addShadowCaster(m)); - /** Включить/выключить рендер VoxelWorld. По умолчанию false — только - * loadFromArray в VoxelWorld для статистики, без отображения. */ - this._voxelRenderEnabled = false; - /** Этап 4 streaming: рендерить только чанки в радиусе от камеры. - * false по умолчанию — рендерим все чанки (для маленьких карт). - * Включается через window.__voxelWorldStreaming(true, 64). */ - this._voxelStreamingEnabled = false; - this._voxelStreamingRadius = 64; // метров - this._voxelStreamingLastUpdate = 0; - this._voxelStreamingInterval = 250; // мс между проверками - if (typeof window !== 'undefined') { - window.__voxelWorldStats = () => { - const s = this.voxelWorld.stats(); - console.log('[VoxelWorld stats]', s); - return s; - }; - // Диагностика FPS bottleneck'ов на больших картах. - // Запускать в консоли когда лагает: window.__voxelPerfReport() - // Debug-команды для диагностики FPS-проблем. - // Запускать в консоли — увидим что реально жрёт CPU/GPU. - window.__toggleShadows = (on) => { - this.setShadowQuality(on === false ? 'off' : 'soft'); - console.log('[Debug] shadows:', on === false ? 'OFF' : 'ON'); - }; - window.__togglePostProcess = (on) => { - if (this.scene && this.scene.postProcessRenderPipelineManager) { - // Babylon не умеет тривиально выключать pipeline, поэтому - // просто отключаем все pipelines - const enabled = on !== false; - if (this._postProcessPipelines) { - for (const p of this._postProcessPipelines) { - try { p.setEnabled(enabled); } catch (e) {} - } - } - } - console.log('[Debug] post-process:', on === false ? 'OFF' : 'ON'); - }; - window.__toggleSceneOptim = (on) => { - // Глобальные оптимизации Babylon - const scn = this.scene; - if (on !== false) { - scn.freezeActiveMeshes(); - scn.skipFrustumClipping = true; - scn.blockfreeActiveMeshesAndRenderingGroups = true; - console.log('[Debug] scene optim: freezeActiveMeshes + skipFrustumClipping ON'); - } else { - scn.unfreezeActiveMeshes(); - scn.skipFrustumClipping = false; - scn.blockfreeActiveMeshesAndRenderingGroups = false; - console.log('[Debug] scene optim: OFF'); - } - }; - window.__voxelPerfReport = () => { - const tm = this.terrainManager; - if (!tm) return console.warn('no terrainManager'); - const scn = this.scene; - const eng = scn.getEngine(); - const totalMeshes = scn.meshes.length; - let activeMeshes = 0; - let activeRegionMeshes = 0; - let activeDecoMeshes = 0; - for (const m of scn.meshes) { - if (m.isEnabled() && m.material) activeMeshes++; - } - if (tm._regionMeshes) { - for (const m of tm._regionMeshes.values()) { - if (m.isEnabled()) activeRegionMeshes++; - } - } - if (this.decoManager?._chunkMeshes) { - for (const colorMap of this.decoManager._chunkMeshes.values()) { - for (const m of colorMap.values()) { - if (m.isEnabled()) activeDecoMeshes++; - } - } - } - // FPS: Babylon engine.getFps() даёт усреднённый, instantaneous - // (1000/getDeltaTime) скачет хаотично из-за GC. - const stableFps = eng.getFps?.() ?? (1000 / eng.getDeltaTime()); - const instFps = 1000 / eng.getDeltaTime(); - // Подсчёт активных треугольников и draw calls. - // Babylon хранит sceneInstrumentation, но он opt-in. - // Считаем вручную из активных мешей. - let activeTriangles = 0; - let activeVertices = 0; - let activeDrawCalls = 0; - if (this.scene && this.scene.meshes) { - for (const m of this.scene.meshes) { - if (!m.isEnabled() || !m.material) continue; - // Frustum cull skip - const idxCount = m.getTotalIndices?.() ?? 0; - if (idxCount === 0) continue; - // thin-instances умножают - const instCount = m.thinInstanceCount > 0 ? m.thinInstanceCount : 1; - activeTriangles += (idxCount / 3) * instCount; - activeVertices += (m.getTotalVertices?.() ?? 0) * instCount; - // 1 draw call на меш (multimat = +submeshes) - const subMeshes = m.subMeshes ? m.subMeshes.length : 1; - activeDrawCalls += subMeshes; - } - } - // Frame time: целевые значения - // 60 FPS = 16.6мс/кадр - // 30 FPS = 33.3мс/кадр - // 23 FPS = 43.5мс/кадр ← наша проблема - const frameMs = eng.getDeltaTime(); - // Visibility — Chrome даёт throttle до 20 FPS если таб неактивен - const docHidden = typeof document !== 'undefined' && document.hidden; - const winFocused = typeof document !== 'undefined' && document.hasFocus?.(); - // PERF-DIAG: где теряется время? - // render_ms — сколько занимает scene.render() (GPU + babylon) - // idle_ms — промежуток между концом render и началом - // следующего кадра (если велик — GPU-bound - // ИЛИ браузер throttle; если мал, а frame_ms - // большой — узкое место в нашем JS до render). - const pm = this._perfMetrics; - const renderMsAvg = pm && pm.render_count - ? (pm.render_ms_sum / pm.render_count) : 0; - const idleMsAvg = pm && pm.idle_count - ? (pm.idle_ms_sum / pm.idle_count) : 0; - // Сбрасываем накопители — следующий отчёт за свежий период. - if (pm) { - pm.render_ms_sum = 0; pm.render_count = 0; - pm.idle_ms_sum = 0; pm.idle_count = 0; - } - console.log('[PerfReport]', { - fps_stable: stableFps.toFixed(1), - fps_instant: instFps.toFixed(1), - frame_ms: frameMs.toFixed(1), - render_ms: renderMsAvg.toFixed(1), - idle_ms: idleMsAvg.toFixed(1), - isPlaying: this._isPlaying, - triangles_K: (activeTriangles / 1000).toFixed(0) + 'K', - drawCalls: activeDrawCalls, - tab_hidden: docHidden, - win_focused: winFocused, - voxelCount: tm.voxels?.size ?? 0, - sceneMeshes: totalMeshes, - activeMeshes, - regionMeshes: tm._regionMeshes?.size ?? 0, - activeRegionMeshes, - decoMeshes: this.decoManager?._chunkMeshes ? this._decoMeshCount() : 0, - activeDecoMeshes, - streamingRadius: this._terrainStreamingRadius, - // Новый TerrainMesh (Roblox-style, voxel) - tmesh_chunks: this._terrainMesh?.chunks?.size ?? 0, - tmesh_pending: this._terrainMesh?._pendingChunks?.size ?? 0, - tmesh_tris: this._terrainMesh ? this._terrainMesh.getActiveTriangles() : 0, - // Roblox Smooth Terrain - rt_chunks: this._robloxTerrain?.chunks?.size ?? 0, - rt_pending: this._robloxTerrain?._pendingChunks?.size ?? 0, - rt_tris: this._robloxTerrain ? this._robloxTerrain.getStats().triangles : 0, - }); - }; - this._decoMeshCount = () => { - let n = 0; - if (!this.decoManager?._chunkMeshes) return 0; - for (const m of this.decoManager._chunkMeshes.values()) n += m.size; - return n; - }; - // === LEAK DETECTOR (dev-only) === - // Если sceneMeshes растёт без явной причины — каждый snapshot - // запоминаем имена мешей, на след. snapshot печатаем НОВЫЕ. - // Точно покажет утечку: какие меши накапливаются. - // Использовать: window.__leakSnap(); потом подождать 5 сек, - // снова window.__leakSnap() — выведет diff. - let _leakLastNames = null; - window.__leakSnap = () => { - const names = this.scene.meshes.map(m => m.name || ''); - if (_leakLastNames === null) { - _leakLastNames = new Map(); - for (const n of names) _leakLastNames.set(n, (_leakLastNames.get(n) || 0) + 1); - console.log('[LeakSnap] baseline:', names.length, 'мешей. Подожди 5+ сек и зови __leakSnap() снова.'); - return; - } - const cur = new Map(); - for (const n of names) cur.set(n, (cur.get(n) || 0) + 1); - const diff = {}; - let totalDiff = 0; - for (const [n, c] of cur) { - const prev = _leakLastNames.get(n) || 0; - if (c > prev) { diff[n] = `+${c - prev} (теперь ${c})`; totalDiff += c - prev; } - } - for (const [n, c] of _leakLastNames) { - if (!cur.has(n)) { diff[n] = `-${c} (удалён)`; totalDiff -= c; } - } - console.log('[LeakSnap] diff:', totalDiff > 0 ? `+${totalDiff}` : totalDiff, diff); - _leakLastNames = cur; - }; - // Автомониторинг FPS — каждые 2 сек пишет PerfReport в devlog. - // Активируется автоматически на localhost. На прод не работает. - window.__perfMonitorStart = (interval = 2000) => { - if (window.__perfMonitorTimer) { - clearInterval(window.__perfMonitorTimer); - } - window.__perfMonitorTimer = setInterval(() => { - try { window.__voxelPerfReport?.(); } catch (e) {} - }, interval); - console.log(`[PerfMonitor] started, interval=${interval}ms`); - }; - window.__perfMonitorStop = () => { - if (window.__perfMonitorTimer) { - clearInterval(window.__perfMonitorTimer); - window.__perfMonitorTimer = null; - console.log('[PerfMonitor] stopped'); - } - }; - // Автостарт мониторинга на localhost — Claude читает devlog.txt - if (typeof window !== 'undefined' - && (window.location.hostname === 'localhost' - || window.location.hostname === '127.0.0.1')) { - setTimeout(() => { try { window.__perfMonitorStart?.(2000); } catch (e) {} }, 1000); - } - // === Тест нового TerrainMesh (Roblox/Minecraft-style) === - // - // Создаёт VoxelGrid и заполняет его holmistym ландшафтом из - // sin-волн. Рендерится через Greedy Meshing. - // Использование в DevTools: - // __terrainTest(64) — небольшая карта 64×16×64м - // __terrainTest(150) — большая 150×24×150м - // __terrainTest(250) — целевая 250×32×250м - window.__terrainTest = (sizeMeters = 64) => { - if (!this._terrainMesh) { - this._terrainMesh = new TerrainMesh(this.scene); - } - const tm = this._terrainMesh; - // Удалим старый legacy terrain — он перекрывает картинку - try { - if (this.terrainManager) this.terrainManager.clear(); - if (this.decoManager) this.decoManager.clear(); - } catch (e) {} - tm.disposeAll(); - - const t0 = performance.now(); - const sx = sizeMeters, sz = sizeMeters; - const sy = 32; - const grid = new VoxelGrid({ - origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 }, - size: { x: sx, y: sy, z: sz }, - }); - // Заполняем heightmap-картой: y = базовый + sin(x)*cos(z) - for (let z = 0; z < sz; z++) { - for (let x = 0; x < sx; x++) { - const fx = (x - sx / 2) / sx; - const fz = (z - sz / 2) / sz; - const h = Math.floor( - 6 + Math.sin(fx * Math.PI * 3) * 4 - + Math.cos(fz * Math.PI * 4) * 3 - + Math.sin((fx + fz) * Math.PI * 6) * 2, - ); - for (let y = 0; y < h && y < sy; y++) { - let mat; - if (y === h - 1) mat = 'grass'; - else if (y >= h - 3) mat = 'dirt'; - else mat = 'rock'; - grid.set(x, y, z, mat); - } - } - } - const tFill = performance.now() - t0; - const solid = grid.countSolid(); - console.log(`[TerrainTest] filled grid ${sx}×${sy}×${sz} (${solid} solid voxels) in ${tFill.toFixed(0)}ms`); - - tm.loadFromGrid(grid); - - // Сразу материализуем ВСЕ chunks (для теста, не lazy) - const t1 = performance.now(); - const camX = this.camera?.position.x || 0; - const camZ = this.camera?.position.z || 0; - const r = tm.updateStreaming(camX, camZ, 9999, { maxBuild: 9999 }); - const tBuild = performance.now() - t1; - const tris = tm.getActiveTriangles(); - console.log(`[TerrainTest] built ${r.built} chunks in ${tBuild.toFixed(0)}ms — ${tris} triangles total`); - console.log(`[TerrainTest] open DevTools → __voxelPerfReport() через 2 сек → должно быть 60+ FPS`); - }; - - // Удалить тестовый terrain mesh - window.__terrainTestClear = () => { - if (this._terrainMesh) { - this._terrainMesh.disposeAll(); - console.log('[TerrainTest] cleared'); - } - }; - - // ============================================================ - // Roblox-style Smooth Terrain test - // ============================================================ - // - // Использование в DevTools: - // __robloxTest(50) — карта 50×16×50 ячеек = 200×64×200 м - // __robloxTest(125) — 500×64×500 м (огромная, ОК для smooth) - // - // Создаёт holmistyy ландшафт через density-функцию и рендерит - // через Surface Nets. Проверка что архитектура работает. - window.__robloxTest = async (gridSize = 50, userParams = null) => { - if (!this._robloxTerrain) { - this._robloxTerrain = new RobloxTerrain(this.scene); - // Подключить к физике — иначе игрок проваливается в smooth terrain - if (this.physics?.setRobloxTerrain) { - this.physics.setRobloxTerrain(this._robloxTerrain); - } - } - const rt = this._robloxTerrain; - try { - if (this.terrainManager) this.terrainManager.clear(); - if (this.decoManager) this.decoManager.clear(); - if (this.voxelWorld) { - const layer = this.voxelWorld.getLayer?.('terrain'); - if (layer && layer.clear) layer.clear(); - } - this._terrainStreamingEnabled = false; - } catch (e) {} - rt.disposeAll(); - - const t0 = performance.now(); - const sx = gridSize, sz = gridSize; - const sy = 24; // высота карты в cells: 24 × 4м = 96м (для гор) - const grid = new RobloxDensityGrid({ - origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 }, - size: { x: sx, y: sy, z: sz }, - }); - - // === Используем тот же WorldGenerator что и voxel-генератор === - // 1 smooth-cell = 4м = 16 voxel-units. - // sampleHeight возвращает высоту в voxel-units (0.25м). - // sampleBiome → объект {topMaterial,softMaterial,hardMaterial,...}. - // - // userParams приходит из UI (TerrainGenPanel buildParams). - // Если null — берём дефолтные. - const params = userParams - ? JSON.parse(JSON.stringify(userParams)) - : JSON.parse(JSON.stringify(DEFAULT_GENERATOR_PARAMS)); - console.log(`[RobloxTest] params: amp=${params.heightmap.amplitude}, scale=${params.heightmap.scale}, exp=${params.heightmap.exponent}, biomes=${params.biomes?.length}`); - const gen = new WorldGenerator(params); - - // Маппинг материалов voxel-генератора → smooth (DensityGrid - // поддерживает только grass/rock/sand/snow). - // dirt → grass, остальные пропускаются. - const matMap = (m) => { - if (m === 'dirt') return 'grass'; - if (m === 'grass' || m === 'rock' || m === 'sand' || m === 'snow') return m; - return 'grass'; - }; - - // Шаг 1: heightmap + biome для каждой smooth-cell. - // x,z в smooth-grid — переводим в voxel-units: vx = (x + origin.x) * 16 - const CELL_VOXELS = 16; // 4м / 0.25м per voxel = 16 - const heightMap = new Float32Array(sx * sz); - const topMats = new Array(sx * sz); - const softMats = new Array(sx * sz); - const hardMats = new Array(sx * sz); - for (let z = 0; z < sz; z++) { - for (let x = 0; x < sx; x++) { - const vx = (x + grid.origin.x) * CELL_VOXELS + CELL_VOXELS / 2; - const vz = (z + grid.origin.z) * CELL_VOXELS + CELL_VOXELS / 2; - const hVoxels = gen.sampleHeight(vx, vz); - const biome = gen.sampleBiome(vx, vz); - const hCells = hVoxels / CELL_VOXELS; - heightMap[z * sx + x] = hCells; - topMats[z * sx + x] = matMap(biome.topMaterial); - softMats[z * sx + x] = matMap(biome.softMaterial); - hardMats[z * sx + x] = matMap(biome.hardMaterial); - } - } - - // Шаг 2: density + материалы. - // Топ-слой: topMaterial биома. - // Средний (1..3 cells вглубь): softMaterial. - // Глубокий (>3 cells): hardMaterial. - for (let z = 0; z < sz; z++) { - for (let x = 0; x < sx; x++) { - const h = heightMap[z * sx + x]; - const topMat = topMats[z * sx + x]; - const softMat = softMats[z * sx + x]; - const hardMat = hardMats[z * sx + x]; - const h1 = x > 0 ? heightMap[z * sx + (x - 1)] : h; - const h2 = x < sx - 1 ? heightMap[z * sx + (x + 1)] : h; - const h3 = z > 0 ? heightMap[(z - 1) * sx + x] : h; - const h4 = z < sz - 1 ? heightMap[(z + 1) * sx + x] : h; - const slope = Math.max( - Math.abs(h - h1), Math.abs(h - h2), - Math.abs(h - h3), Math.abs(h - h4), - ); - // На очень крутых обрывах (>3 cells = 12м перепад) — - // обнажение rock даже на травяных склонах. - const useRockSlope = slope > 3.0 && topMat !== 'sand' && topMat !== 'snow'; - - for (let y = 0; y < sy; y++) { - const delta = h - y; - let densityF; - if (delta > 2) densityF = 1; - else if (delta < -2) densityF = 0; - else densityF = (delta + 2) / 4; - const density = (densityF * 255) | 0; - if (density > 0) { - let mat; - if (useRockSlope) mat = 'rock'; - else if (delta < 1) mat = topMat; - else if (delta < 3) mat = softMat; - else mat = hardMat; - grid.set(x, y, z, density, mat); - } - } - } - } - const tFill = performance.now() - t0; - console.log(`[RobloxTest] filled grid ${sx}×${sy}×${sz} (${grid.countSolid()} solid cells) in ${tFill.toFixed(0)}ms`); - - rt.loadFromGrid(grid); - - // Материализуем ВСЕ chunks сразу для теста. - const t1 = performance.now(); - const camX = this.camera?.position.x || 0; - const camZ = this.camera?.position.z || 0; - const r = rt.updateStreaming(camX, camZ, 99999, { maxBuild: 9999 }); - const tBuild = performance.now() - t1; - const stats = rt.getStats(); - console.log(`[RobloxTest] built ${r.built} chunks in ${tBuild.toFixed(0)}ms — ${stats.triangles} triangles`); - - // Мини-карта для свежесгенерированного гладкого ландшафта. - this._setupMinimapForRobloxTerrain(); - - // === Авто-спавн над поверхностью === - // Находим в grid самую верхнюю solid-ячейку в столбце x=0, z=0. - // Spawn = top_y + 2м над ней. - const CS = 4; // CELL_SIZE - const cellX0 = 0 - grid.origin.x; // мировые (0,_,0) → cell - const cellZ0 = 0 - grid.origin.z; - let topCellY = -1; - for (let cy = sy - 1; cy >= 0; cy--) { - if (grid.isSolid(cellX0, cy, cellZ0)) { topCellY = cy; break; } - } - if (topCellY >= 0) { - const surfaceY = (grid.origin.y + topCellY + 1) * CS; - this._spawnPoint = { x: 0, y: surfaceY + 2, z: 0 }; - this._updateSpawnMarker?.(); - console.log(`[RobloxTest] auto-spawn at y=${surfaceY + 2} (surface at y=${surfaceY})`); - } - - // Отключаем baseplate-пол — иначе он закрывает обзор и - // создаёт коллизии под smooth-ландшафтом. - try { this.setFloorEnabled(false); } catch (e) {} - - // === Декорации (цветы / трава / грибы) === - // Размещаем 3D-модели Kenney Nature Kit через thin-instances. - // Используем те же sampleHeight/sampleBiome из WorldGenerator - // что и для terrain — биомы определят какие декорации куда идут. - const decoOpts = userParams?.smoothDeco ?? { - flowersDensity: 0.025, - grassDensity: 0.10, - treesDensity: 0.4, - }; - // Сохраняем параметры для сериализации (при load воссоздадим) - this._smoothDecoParams = { - flowersDensity: decoOpts.flowersDensity, - grassDensity: decoOpts.grassDensity, - treesDensity: decoOpts.treesDensity ?? 0.4, - seed: params.seed || 1337, - bbox: { - minX: -(sx * CS) / 2, maxX: (sx * CS) / 2, - minZ: -(sz * CS) / 2, maxZ: (sz * CS) / 2, - }, - // Параметры WorldGenerator нужны для воссоздания biome-маппинга - genParams: params, - }; - if (decoOpts.flowersDensity > 0 || decoOpts.grassDensity > 0 || (decoOpts.treesDensity ?? 0) > 0) { - try { - if (!this._smoothDecoManager) { - this._smoothDecoManager = new SmoothDecoManager(this.scene); - } - const tDeco0 = performance.now(); - await this._smoothDecoManager.loadAll(); - const tDecoLoad = performance.now() - tDeco0; - // bbox в мировых координатах (метры) - const halfMeters = (sx * CS) / 2; - const bbox = { - minX: -halfMeters, maxX: halfMeters, - minZ: -halfMeters, maxZ: halfMeters, - }; - // Хелпер для surface raycast (использует физику) - const sampleSurfaceY = (x, z) => { - if (!this.physics?._sampleRobloxSurface) return null; - return this.physics._sampleRobloxSurface(x, z); - }; - const sampleBiomeId = (x, z) => { - // x,z в метрах → voxel-units (×4) - const vx = x * 4; - const vz = z * 4; - const biome = gen.sampleBiome(vx, vz); - return biome?.id; - }; - const tDeco1 = performance.now(); - const r = this._smoothDecoManager.placeDecorations({ - sampleSurfaceY, sampleBiomeId, bbox, - densityFlowers: decoOpts.flowersDensity, - densityGrass: decoOpts.grassDensity, - densityTrees: decoOpts.treesDensity ?? 0, - seed: params.seed || 1337, - }); - const tDecoPlace = performance.now() - tDeco1; - console.log(`[RobloxTest] decorations: load ${tDecoLoad.toFixed(0)}ms + place ${tDecoPlace.toFixed(0)}ms → ${r.total} instances`); - // Регистрация tree-AABB в физике — игрок не пройдёт сквозь стволы. - if (this.physics?.setSmoothDecoTrees && r.treeColliders) { - this.physics.setSmoothDecoTrees(r.treeColliders); - } - } catch (e) { - console.error('[RobloxTest] decorations failed:', e); - } - } - - // Перемещаем редактор-камеру повыше чтобы видеть весь рельеф - if (this.camera && topCellY >= 0) { - const surfaceY = (grid.origin.y + topCellY + 1) * CS; - this.camera.position.x = sx * CS * 0.3; - this.camera.position.y = surfaceY + 30; - this.camera.position.z = sz * CS * 0.3; - this.camera.setTarget?.(new Vector3(0, surfaceY, 0)); - } - }; - - window.__robloxTestClear = () => this.clearRobloxTerrain(); - - // Этап 7a: процедурный генератор - // window.__voxelGenerate({size:160, params:{...}}) — генерирует - // террейн в bbox [-size..+size] и заменяет существующий terrain. - window.__voxelGenerate = async (opts = {}) => { - // ГЛОБАЛЬНЫЙ лок (на window, не на this!). - // Без него при HMR (hot module reload в dev) каждая копия - // BabylonScene имеет свой this._voxelGenerating, и команда - // в консоли вызывает все копии параллельно. - // window.__voxelGenLock виден ВСЕМ копиям сцены. - if (window.__voxelGenLock) { - console.warn('[VoxelGen] already running, ignoring duplicate call'); - return null; - } - window.__voxelGenLock = true; - // size — half-size в voxel-units (0.25м/voxel). - // Картa = size × 2 × 0.25м. - // size=160 → 80×80м (~200K voxels, FPS 27) ← по умолчанию - // size=200 → 100×100м (~400K voxels, FPS 25) ← МАКСИМУМ - // Жёсткий лимит — 200 (карта 100×100м максимум). - // Для больших карт используйте Roblox-style smooth terrain. - try { - const MAX_SIZE = 200; - let size = opts.size ?? 160; - if (size > MAX_SIZE) { - console.warn(`[VoxelGen] size=${size} превышает лимит ${MAX_SIZE} (карта >100м). Обрезаю до ${MAX_SIZE}.`); - size = MAX_SIZE; - } - const params = opts.params ?? DEFAULT_GENERATOR_PARAMS; - // Сохраняем для мини-карты (MinimapOverlay читает window.__lastGenParams) - window.__lastGenParams = params; - window.__lastGenSize = size; - console.log(`[VoxelGen] generating ${size*2}×${size*2} voxel-units (${(size * 2 * 0.25).toFixed(0)}m × ${(size * 2 * 0.25).toFixed(0)}m)…`); - - // ВАЖНО: пишем в LEGACY TerrainManager — он рендерит правильно - // (MultiCube для grass:top/side/bottom работает, текстуры - // настроены корректно). VoxelWorld остаётся как shadow-copy - // для RLE-сжатия в БД, но не для рендера. - // - // VoxelRenderer (новый) пока что выключен — он показывал - // серую кашу из-за проблем с MultiCube. - this._voxelRenderEnabled = false; - if (this.voxelRenderer) { - this.voxelRenderer.dispose(); - this.voxelRenderer = new VoxelRenderer(this.voxelWorld, this.scene); - this.voxelRenderer.setOnMeshCreated((m) => this.addShadowCaster(m)); - } - - // Progress callback — UI подхватывает через window.__voxelGenProgress. - const onProgress = (done, total, phase) => { - const pct = Math.min(100, Math.round((done / total) * 100)); - if (window.__voxelGenProgress) { - try { window.__voxelGenProgress(pct, phase); } catch (e) {} - } - }; - onProgress(0, 100, 'starting'); - - // Этап C оптимизации: генерация в Web Worker'е. - // Main thread не блокируется, UI отзывчив, progress-bar плавный. - const { getTerrainGenWorkerUrl } = await import('./TerrainGenWorker'); - const workerUrl = getTerrainGenWorkerUrl(); - const worker = new Worker(workerUrl); - - let voxels, decorations, treesPlaced, statsTimeMs; - try { - await new Promise((resolve, reject) => { - worker.onmessage = (e) => { - const m = e.data; - if (m.type === 'progress') { - onProgress(m.done, m.total, m.phase); - } else if (m.type === 'done') { - voxels = m.voxels; - decorations = m.decorations; - treesPlaced = m.treesPlaced; - statsTimeMs = m.timeMs; - resolve(); - } else if (m.type === 'error') { - reject(new Error('[Worker] ' + m.message)); - } - }; - worker.onerror = (err) => { - reject(new Error('[Worker] crash: ' + err.message)); - }; - worker.postMessage({ - type: 'generate', - params, - bbox: { x0: -size, z0: -size, x1: size, z1: size }, - }); - }); - } finally { - worker.terminate(); - URL.revokeObjectURL(workerUrl); - } - console.log(`[VoxelGen] generated ${voxels.length} voxels in ${statsTimeMs}ms (worker), ${treesPlaced} trees, ${decorations?.length || 0} decorations`); - onProgress(95, 100, 'render'); - await new Promise(r => setTimeout(r, 0)); - - // Заливаем в legacy TerrainManager (он отрендерит правильно). - // Очищаем TerrainMesh если был — на новых генерациях не нужен. - if (this._terrainMesh) { - try { this._terrainMesh.disposeAll(); } catch (e) {} - } - if (this.terrainManager) { - this.terrainManager.clear(); - if (this.terrainManager.loadFromArray.constructor.name === 'AsyncFunction') { - await this.terrainManager.loadFromArray(voxels); - } else { - this.terrainManager.loadFromArray(voxels); - } - console.log(`[VoxelGen] loaded into legacy TerrainManager`); - - // Также пишем в VoxelWorld для RLE-сжатия в БД - try { - const vwLayer = this.voxelWorld.getOrCreateLayer('terrain', 0.25); - vwLayer.clear(); - vwLayer.loadFromArray(voxels); - } catch (e) { /* ignore */ } - - // АВТОВКЛЮЧЕНИЕ STREAMING для больших карт. - const regionCount = this.terrainManager.getRegionCount?.() ?? 0; - if (regionCount > 0) { - this._terrainStreamingEnabled = true; - // Адаптивный radius по количеству вокселей: чем больше - // карта, тем меньше radius (иначе слишком много рендерится). - // <300K voxels → 40м (норма для маленьких карт) - // 300K-1M → 36м - // 1M-2M → 32м - // >2M → 28м (очень большие) - const vc = voxels.length; - let radius = 40; - if (vc > 2_000_000) radius = 28; - else if (vc > 1_000_000) radius = 32; - else if (vc > 300_000) radius = 36; - this._terrainStreamingRadius = radius; - this._terrainStreamingLastUpdate = 0; - const cam = this.camera; - if (cam) { - const r = this.terrainManager.updateStreaming(cam.position.x, cam.position.z, this._terrainStreamingRadius); - console.log(`[VoxelGen] streaming ON: radius=${this._terrainStreamingRadius}m (${vc} voxels), ${r.enabled}/${r.total} regions enabled`); - } - } else { - this._terrainStreamingEnabled = false; - } - } - - // Этап 6: загружаем decorations (мини-воксельные цветы/грибы). - if (this.decoManager && decorations) { - this.decoManager.loadFromArray(decorations); - // Этап D: первый pass LOD streaming для деко. - // maxBuild=2 — деко достроятся плавно через updateStreaming. - if (this.camera && this.decoManager.updateStreaming) { - const decoRadius = Math.max(18, (this._terrainStreamingRadius || 40) * 0.35); - this.decoManager.updateStreaming( - this.camera.position.x, this.camera.position.z, decoRadius, - { maxBuild: 2 }, - ); - } - } - - onProgress(100, 100, 'done'); - return { voxels: voxels.length, treesPlaced, decorations: decorations?.length || 0, timeMs: statsTimeMs }; - } finally { - // Снять глобальный лок ОБЯЗАТЕЛЬНО. - window.__voxelGenLock = false; - } - }; - // Готовые пресеты для быстрого теста - window.__voxelPresets = { - default: DEFAULT_GENERATOR_PARAMS, - mountains: { - ...DEFAULT_GENERATOR_PARAMS, - heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 50, exponent: 2.0 }, - }, - flat: { - ...DEFAULT_GENERATOR_PARAMS, - heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 3, exponent: 1.0 }, - }, - islands: { - ...DEFAULT_GENERATOR_PARAMS, - heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 15, exponent: 2.5 }, - }, - forest: { - ...DEFAULT_GENERATOR_PARAMS, - biomes: DEFAULT_GENERATOR_PARAMS.biomes.map(b => - b.id === 'plain' || b.id === 'forest' - ? { ...b, features: { ...b.features, trees: 1.5 } } - : b, - ), - }, - }; - - // Этап 4: streaming контроль - window.__voxelWorldStreaming = (enabled, radius = 64) => { - this._voxelStreamingEnabled = !!enabled; - this._voxelStreamingRadius = radius; - if (!this._voxelRenderEnabled) { - console.log('[VoxelWorld] streaming on, но render выключен. Включи render: window.__voxelWorldRender(true)'); - return; - } - if (!this._voxelStreamingEnabled) { - // Сброс: загрузить все чанки обратно - this.voxelRenderer.rebuildAll(); - console.log('[VoxelWorld] streaming OFF — все чанки видимы'); - return; - } - // Стартовый update вокруг камеры - const cam = this.camera; - const center = { x: cam.position.x, z: cam.position.z }; - const r = this.voxelRenderer.updateStreaming(center, radius); - console.log(`[VoxelWorld] streaming ON, radius=${radius}m: ${r.loaded} loaded, ${r.unloaded} unloaded, ${this.voxelRenderer.getActiveChunkCount()}/${r.total} active`); - }; - // Этап 3 benchmark: сравнить размер legacy JSON vs RLE+base64 - window.__voxelWorldBenchmarkRLE = () => { - const t0 = performance.now(); - const rleData = this.voxelWorld.serialize(); - const t1 = performance.now(); - const rleJson = JSON.stringify(rleData); - const t2 = performance.now(); - const rleBytes = new Blob([rleJson]).size; - - // Legacy формат для сравнения — массив {x,y,z,m} - const legacyVoxels = []; - const layer = this.voxelWorld.getLayer('terrain'); - if (layer) { - for (const ch of layer.chunks.values()) { - const ox = ch.voxelOriginX(); - const oy = ch.voxelOriginY(); - const oz = ch.voxelOriginZ(); - for (let i = 0; i < 32768; i++) { - const idx = ch.data[i]; - if (idx === 0) continue; - const m = layer.matIdxToId(idx); - const lx = i % 32; - const lz = ((i / 32) | 0) % 32; - const ly = (i / 1024) | 0; - legacyVoxels.push({ x: ox + lx, y: oy + ly, z: oz + lz, m }); - } - } - } - const t3 = performance.now(); - const legacyJson = JSON.stringify(legacyVoxels); - const legacyBytes = new Blob([legacyJson]).size; - const t4 = performance.now(); - - const ratio = (legacyBytes / rleBytes).toFixed(1); - const sizes = this.voxelWorld.measureSize(); - console.log('[RLE Benchmark]'); - console.log(` Legacy JSON: ${(legacyBytes / 1024).toFixed(0)} KB (serialize: ${(t4 - t2).toFixed(0)} ms)`); - console.log(` RLE+base64: ${(rleBytes / 1024).toFixed(0)} KB (serialize: ${(t2 - t0).toFixed(0)} ms)`); - console.log(` Уменьшение: ${ratio}× меньше`); - console.log(' Подробно:', sizes); - return { legacy: legacyBytes, rle: rleBytes, ratio }; - }; - - // Чистый benchmark mesh-build без создания Babylon-meshей. Это - // показывает скорость алгоритма greedy в отрыве от GPU. - window.__voxelWorldBenchmark = async () => { - const { buildChunkGeometryGreedy } = await import('./voxel/GreedyMesher'); - const { buildChunkGeometry } = await import('./voxel/ChunkMesher'); - const layer = this.voxelWorld.getLayer('terrain'); - if (!layer) { console.warn('no terrain layer'); return; } - const neighborMatIdx = (gx, gy, gz) => layer.getMatIdx(gx, gy, gz); - - // Surface culling (Этап 1) - let totalFacesNonGreedy = 0; - const t1 = performance.now(); - for (const ch of layer.chunks.values()) { - const r = buildChunkGeometry(ch, layer, neighborMatIdx); - totalFacesNonGreedy += r.totalFaces; - } - const dt1 = performance.now() - t1; - - // Greedy (Этап 2) - let totalFacesGreedy = 0; - const t2 = performance.now(); - for (const ch of layer.chunks.values()) { - const r = buildChunkGeometryGreedy(ch, layer, neighborMatIdx); - totalFacesGreedy += r.totalFaces; - } - const dt2 = performance.now() - t2; - - const reduction = ((1 - totalFacesGreedy / totalFacesNonGreedy) * 100).toFixed(1); - console.log(`[Benchmark] Surface culling: ${totalFacesNonGreedy} quads in ${dt1.toFixed(0)}ms`); - console.log(`[Benchmark] Greedy meshing: ${totalFacesGreedy} quads in ${dt2.toFixed(0)}ms — на ${reduction}% меньше квадров`); - return { surfaceCulling: { quads: totalFacesNonGreedy, ms: dt1 }, - greedy: { quads: totalFacesGreedy, ms: dt2 }, - reduction: `${reduction}%` }; - }; - window.__voxelWorldRender = (enabled) => { - this._voxelRenderEnabled = !!enabled; - if (this._voxelRenderEnabled) { - // Прячем legacy TerrainManager mesh'и - for (const proto of this.terrainManager._protoMeshes?.values?.() ?? []) { - proto.setEnabled(false); - } - // Если streaming ON — грузим только видимые чанки - // (rebuildAll потом бы их сразу half-выгрузил, лишняя работа). - if (this._voxelStreamingEnabled && this.camera) { - const cam = this.camera; - const r = this.voxelRenderer.updateStreaming( - { x: cam.position.x, z: cam.position.z }, - this._voxelStreamingRadius, - ); - console.log(`[VoxelWorld] render ENABLED (streaming): ${r.loaded} loaded, ${this.voxelRenderer.getActiveChunkCount()}/${r.total} active`); - } else { - this.voxelRenderer.rebuildAll(); - console.log('[VoxelWorld] render ENABLED, legacy hidden'); - } - } else { - // Показываем legacy обратно, скрываем новый - for (const proto of this.terrainManager._protoMeshes?.values?.() ?? []) { - proto.setEnabled(true); - } - this.voxelRenderer.dispose(); - console.log('[VoxelWorld] render DISABLED, legacy restored'); - } - }; - } - // Состояние brush'а ландшафта, обновляется из TerrainPanel. - // tool — 'select'|'transform'|'fill'|'sealevel'|'draw'|'sculpt'|'smooth'|'paint'|'flatten' - // material — id из TERRAIN_MATERIALS - // brushSize — радиус кисти в voxel'ах - // strength — 1..100 - // shape — 'sphere'|'cube'|'cylinder' - this._terrainBrush = { - tool: 'sculpt', - material: 'grass', - brushSize: 4, - strength: 50, - shape: 'sphere', - // terrainMode: 'voxel' (по умолчанию) | 'smooth'. - // В smooth-режиме кисти редактируют DensityGrid через SmoothBrushes, - // в voxel — TerrainManager.voxels (как раньше). - terrainMode: 'voxel', - }; - // Полупрозрачный preview-меш под курсором (показывает где будет кисть) - this._terrainBrushPreview = null; - this.modelManager = new ModelManager(this.scene, this); - this.modelManager.setScene3D(this); - // Делаем ModelManager доступным через scene — MultiplayerSync.js - // подхватывает его для shared-кэша GLB-прототипов. - this.scene._kubikonModelManager = this.modelManager; - - // Этап 5 редактора моделей: менеджер пользовательских voxel-моделей. - // API подключается отдельно через setUserModelsApi (см. ниже), - // потому что Kubikon3DService импортируется через ES-modules. - this.userModelManager = new UserModelManager(this.scene); - // Глобальная функция для отладки: window.__kubikonDebugColliders() - // выводит в консоль все коллайдеры моделей и примитивов. - if (typeof window !== 'undefined') { - window.__kubikonDebugColliders = () => { - const out = []; - if (this.modelManager) { - for (const data of this.modelManager.instances.values()) { - const a = data.localAABB; - if (a) { - const w = (a.maxX - a.minX).toFixed(2); - const h = (a.maxY - a.minY).toFixed(2); - const d = (a.maxZ - a.minZ).toFixed(2); - out.push({ - kind: 'model', id: data.modelTypeId, - pos: [data.x.toFixed(1), data.y.toFixed(1), data.z.toFixed(1)], - size: [w, h, d], - canCollide: data.canCollide, - }); - } - } - } - if (this.primitiveManager) { - for (const data of this.primitiveManager.instances.values()) { - out.push({ - kind: 'primitive', type: data.type, - pos: [data.x.toFixed(1), data.y.toFixed(1), data.z.toFixed(1)], - size: [data.sx, data.sy, data.sz], - canCollide: data.canCollide, - }); - } - } - console.table(out); - return out; - }; - } - this.primitiveManager = new PrimitiveManager(this.scene); - // Ссылка на обёртку BabylonScene — нужна для эмиттеров частиц - // (createEmitterParticles живёт на обёртке). - this.primitiveManager.scene3d = this; - // BillboardUiManager — отдельный модуль, рисует GUI на DynamicTexture - // для билбордов. Нужен PrimitiveManager-у чтобы при создании billboard - // (type='billboard') сразу применить текстуру с дефолтным пресетом. - this.billboardUiManager = new BillboardUiManager(this.scene); - this.primitiveManager.billboardUiManager = this.billboardUiManager; - this.folderManager = new FolderManager(this.blockManager, this.modelManager, this.primitiveManager); - this.guiManager = new GuiManager(); - this.modalManager = new ModalManager(); - this.modalManager.attachScene(this); - this.modalManager.attachGui(this.guiManager); - this.inventory = new InventoryManager(); - this.physics = new PhysicsAABB(this.blockManager); - // Сразу синхронизируем границу пола с текущим размером мира, - // иначе при дефолтных 40 игрок проваливается на больших картах - // ещё до первого setWorldSize(). - this.physics.floorHalf = this._worldHalf; - this.physics.setPrimitiveManager(this.primitiveManager); - this.physics.setModelManager(this.modelManager); - this.physics.setUserModelManager(this.userModelManager); - // Voxel-террейн тоже участвует в физике. У террейна свой размер - // ячейки (TERRAIN_VOXEL_SIZE = 0.5), поэтому передаём отдельно. - this.physics.setTerrainManager(this.terrainManager, TERRAIN_VOXEL_SIZE); - // Этап 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); - - // 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 — Screen-Space Ambient Occlusion (контактные тени в углах, - // под объектами и в стыках). По умолчанию выключен — это дорогой - // пост-эффект (-15..30% FPS). Включается через setSsaoEnabled(true) / - // setLightingProps({ ssaoEnabled: true }) из инспектора Lighting. - this._ssaoPipeline = null; - this._ssaoEnabled = false; - } - - /** - * Включить/выключить SSAO пост-эффект (контактные тени). - * Используем SSAORenderingPipeline v1 — v2 ломал thin-instance рендер - * (блоки/террейн пропадали) из-за GeometryBufferRenderer. v1 использует - * только depthRenderer и совместим со всеми мешами. - */ - setSsaoEnabled(on) { - const want = !!on; - if (this._ssaoEnabled === want && (!want || this._ssaoPipeline)) return; - if (!want) { - this._disposeSsaoPipeline(); - this._ssaoEnabled = false; - return; - } - if (!this.scene.activeCamera) { - console.warn('[BabylonScene] SSAO: нет активной камеры'); - return; - } - try { - const ratio = { ssaoRatio: 0.5, combineRatio: 1.0 }; - const pipeline = new SSAORenderingPipeline( - 'ssaopipeline', this.scene, ratio, [this.scene.activeCamera] - ); - pipeline.fallOff = 0.000001; - pipeline.area = 0.0075; - pipeline.radius = 0.0001; - pipeline.totalStrength = 1.0; - pipeline.base = 0.5; - this._ssaoPipeline = pipeline; - this._ssaoEnabled = true; - } catch (e) { - console.warn('[BabylonScene] SSAO не запустился:', e?.message || e); - this._disposeSsaoPipeline(); - this._ssaoEnabled = false; - } - } - - /** Полностью убрать SSAO пайплайн (detach + remove + dispose). */ - _disposeSsaoPipeline() { - if (!this._ssaoPipeline) return; - const mgr = this.scene.postProcessRenderPipelineManager; - const name = this._ssaoPipeline.name || 'ssaopipeline'; - try { - if (mgr && this.scene.activeCamera) { - mgr.detachCamerasFromRenderPipeline?.(name, this.scene.activeCamera); - } - } catch (e) { /* ignore */ } - try { - if (mgr && typeof mgr.removePipeline === 'function') { - mgr.removePipeline(name); - } - } catch (e) { /* ignore */ } - try { this._ssaoPipeline.dispose(); } catch (e) { /* ignore */ } - this._ssaoPipeline = null; - } - - getSsaoEnabled() { return this._ssaoEnabled; } - - /** Создаёт ShadowGenerator (если ещё нет) и применяет текущее качество. - * - * Поддерживаемые уровни (Этап 2 теней, 2026-05-27): - * - 'off' — теней нет - * - 'hard' — резкие тени, 512px, без блюра - * - 'soft' — мягкие тени, 1024px (на mobile 512), blurKernel 24 - * - 'medium' — CSM 1024 × 3 каскада, для среднего ПК - * - 'high' — CSM 2048 × 4 каскада, дорогой, для топовых ПК - */ - _ensureShadowGenerator() { - const q = this._shadowQuality; - if (q === 'off') { - if (this._shadowGenerator) { - try { this._shadowGenerator.dispose(); } catch (e) { /* ignore */ } - this._shadowGenerator = null; - } - return null; - } - // Если уже создан — но качество поменялось на тот тип где нужен другой - // движок (CSM vs обычный) — пересоздадим. - const wantCsm = (q === 'medium' || q === 'high'); - const haveCsm = this._shadowGenerator instanceof CascadedShadowGenerator; - if (this._shadowGenerator && wantCsm !== haveCsm) { - try { this._shadowGenerator.dispose(); } catch (e) { /* ignore */ } - this._shadowGenerator = null; - } - - // PCF = Percentage Closer Filtering. Правильная техника мягких теней. - // - // bias 0.0005, normalBias 0.005. Раньше normalBias=0.012 давал - // peter-panning — тень "уезжала" далеко в сторону от блока (баг - // 2026-05-27). 0.005 — баланс между acne и peter-panning для - // воксельных кубов 1м. - const PCF_BIAS = 0.0005; - const PCF_NORMAL_BIAS = 0.005; - - if (!this._shadowGenerator) { - if (wantCsm) { - // CSM с PCF. Поднял разрешение каскадов (2048/4096 — было 1024/2048). - const size = (q === 'high') ? 4096 : 2048; - const numCascades = (q === 'high') ? 4 : 3; - const csm = new CascadedShadowGenerator(size, this._sunLight); - csm.numCascades = numCascades; - csm.stabilizeCascades = true; - csm.lambda = 0.8; - 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 (было 1024). - let shadowSize; - if (q === 'hard') { - shadowSize = this._isMobileMode ? 512 : 1024; - } else { // soft - shadowSize = this._isMobileMode ? 1024 : 2048; - } - const gen = new ShadowGenerator(shadowSize, this._sunLight); - gen.bias = PCF_BIAS; - gen.normalBias = PCF_NORMAL_BIAS; - if (gen.getShadowMap) { - const rtt = gen.getShadowMap(); - if (rtt) rtt.refreshRate = 2; - } - this._shadowGenerator = gen; - } - } - - const gen = this._shadowGenerator; - if (q === 'medium' || q === 'high') { - // параметры CSM выставлены при создании - } else if (q === 'soft') { - gen.usePercentageCloserFiltering = true; - gen.filteringQuality = ShadowGenerator.QUALITY_MEDIUM; - gen.useBlurExponentialShadowMap = false; - gen.useKernelBlur = false; - gen.usePoissonSampling = false; - gen.darkness = 0.4; - } else { // hard - gen.usePercentageCloserFiltering = false; - gen.useBlurExponentialShadowMap = false; - gen.useKernelBlur = false; - gen.usePoissonSampling = false; - gen.darkness = 0.55; - } - return gen; - } - - /** - * Изменить качество теней. 'off' уничтожает генератор; 'hard'/'soft'/ - * 'medium'/'high' создают/обновляют. CSM используется для medium/high. - */ - setShadowQuality(q) { - const allowed = ['off', 'hard', 'soft', 'medium', 'high']; - if (!allowed.includes(q)) return; - this._shadowQuality = q; - this._ensureShadowGenerator(); - // Если выключили — снимем receiveShadows с пола (необязательно, но чище) - const ground = this.scene.getMeshByName('editorGround'); - if (ground) ground.receiveShadows = q !== 'off'; - // После смены качества — заново зарегистрировать всех casters - // (при пересоздании генератора список обнулился). - if (q !== 'off') { - try { this.refreshAllShadows(); } catch (e) { /* ignore */ } - } - } - - getShadowQuality() { return this._shadowQuality || 'soft'; } - - /** - * Установить свойства глобального освещения. Вызывается из Inspector - * (selection.type === 'lighting'). - * patch: { sunIntensity?, hemiIntensity?, hemiGround?, fogEnabled?, - * fogDensity?, fogColor?, shadowQuality? } - */ - setLightingProps(patch) { - if (!patch) return; - // Время суток — пресет / минуты день/ночь - if (patch.envPreset && this.environment) { - try { this.environment.setPreset(patch.envPreset); } catch (e) { /* ignore */ } - } - if (typeof patch.dayDurationMin === 'number' && patch.dayDurationMin > 0 && this.environment) { - this.environment.setCycleDuration(patch.dayDurationMin, this.environment.nightDurationMin); - } - if (typeof patch.nightDurationMin === 'number' && patch.nightDurationMin > 0 && this.environment) { - this.environment.setCycleDuration(this.environment.dayDurationMin, patch.nightDurationMin); - } - if (typeof patch.sunIntensity === 'number' && this._sunLight) { - this._sunLight.intensity = Math.max(0, patch.sunIntensity); - } - if (typeof patch.hemiIntensity === 'number' && this._hemiLight) { - this._hemiLight.intensity = Math.max(0, patch.hemiIntensity); - } - if (this.environment && typeof this.environment.setFog === 'function') { - // Текущие значения берём из Environment, поверх накладываем patch - const enabled = (typeof patch.fogEnabled === 'boolean') - ? patch.fogEnabled : this.environment.fogEnabled; - let color = this.environment.fogColor; - if (patch.fogColor && /^#[0-9a-fA-F]{6}$/.test(patch.fogColor)) { - color = [ - parseInt(patch.fogColor.substr(1, 2), 16) / 255, - parseInt(patch.fogColor.substr(3, 2), 16) / 255, - parseInt(patch.fogColor.substr(5, 2), 16) / 255, - ]; - } - const density = (typeof patch.fogDensity === 'number') - ? patch.fogDensity : this.environment.fogDensity; - if ('fogEnabled' in patch || 'fogDensity' in patch || 'fogColor' in patch) { - this.environment.setFog(enabled, color, density); - } - } - if (patch.shadowQuality) { - this.setShadowQuality(patch.shadowQuality); - this.refreshAllShadows(); - } - if (typeof patch.ssaoEnabled === 'boolean') { - this.setSsaoEnabled(patch.ssaoEnabled); - } - // Обновить selection чтобы Inspector сразу показывал новые значения - if (this.selection?._selection?.type === 'lighting') { - this.selection.selectLighting(); - } - } - - /** - * Сгруппировать текущие выделенные объекты в новую папку (Ctrl+G). - * Если выделен один — кладёт его одного. Если ничего — no-op. - */ - groupSelected(name = null) { - if (!this.folderManager || !this.selection) return null; - const multi = this.selection.getMultiSelection(); - const items = []; - if (multi.length > 0) { - for (const it of multi) items.push(it); - } else { - const s = this.selection.getSelection(); - if (!s) return null; - if (s.type === 'block') items.push({ kind: 'block', ref: { x: s.gridX, y: s.gridY, z: s.gridZ } }); - else if (s.type === 'model') items.push({ kind: 'model', ref: s.instanceId }); - else if (s.type === 'primitive') items.push({ kind: 'primitive', ref: s.id }); - else return null; - } - if (items.length === 0) return null; - const folderName = name || `Группа ${this.folderManager.getAll().length + 1}`; - const folderId = this.folderManager.createFolder(folderName, null); - for (const it of items) { - this.folderManager.assignToFolder(it.kind, it.ref, folderId); - } - return folderId; - } - - /** - * Зарегистрировать меш как «отбрасывающий тень». Безопасно вызывать многократно. - * ВАЖНО: только настоящие Mesh (с геометрией), а не TransformNode-узлы. - * ShadowGenerator вызывает getBoundingInfo()/getVerticesData() — у TransformNode - * этих методов нет, что приводит к runtime-крашу. - */ - /** Удалено: пытались через ShadowGenerator, не сработало. - * Теперь тени делает GdGroundSkin через синтетические «тени-кружки». */ - _enableGdShadows() { /* no-op */ } - - addShadowCaster(mesh) { - if (!this._shadowGenerator || !mesh) return; - // TransformNode не имеет getBoundingInfo/getVerticesData - if (typeof mesh.getBoundingInfo !== 'function') return; - if (typeof mesh.getTotalVertices !== 'function') return; - if (mesh.getTotalVertices() <= 0) return; - try { this._shadowGenerator.addShadowCaster(mesh, false); } catch (e) { /* ignore */ } - } - - /** - * Зарегистрировать все текущие блоки/модели/примитивы как shadow casters. - * Полезно вызвать после loadFromState или смены качества теней. - */ - refreshAllShadows() { - if (!this._shadowGenerator) return; - if (this.blockManager) { - // Для thin-instance proto-мешей: один addShadowCaster на тип = все инстансы - if (this.blockManager._protoMeshes) { - for (const proto of this.blockManager._protoMeshes.values()) { - this.addShadowCaster(proto); - } - } - // Жидкости/legacy mesh - for (const m of this.blockManager.blocks.values()) { - if (m && typeof m.getBoundingInfo === 'function') this.addShadowCaster(m); - } - } - if (this.modelManager) { - for (const inst of this.modelManager.instances.values()) { - const root = inst.rootMesh; - if (!root) continue; - // root обычно TransformNode → пропускаем сам root, добавляем только child-mesh'ы - if (typeof root.getChildMeshes === 'function') { - for (const cm of root.getChildMeshes()) this.addShadowCaster(cm); - } - } - } - if (this.primitiveManager) { - for (const inst of this.primitiveManager.instances.values()) { - if (inst.mesh) this.addShadowCaster(inst.mesh); - } - } - } - - _createGroundGrid() { - // Размер мира — настраивается через setWorldSize(). Пол идёт от -WORLD_HALF до +WORLD_HALF. - const WORLD_HALF = this._worldHalf; - - const ground = MeshBuilder.CreateGround( - 'editorGround', - { width: WORLD_HALF * 2, height: WORLD_HALF * 2, subdivisions: 1 }, - this.scene - ); - - // Baseplate-текстура (как в Roblox Studio): процедурный клетчатый паттерн. - // Рисуем в DynamicTexture — каждая «плитка» 1×1 на грани соответствует - // 1 единице мира. Делаем 64×64 пикселей — каждый пиксель = 1 квадрат. - const TEX_SIZE = 64; - const baseTex = new DynamicTexture('baseplateTex', { width: TEX_SIZE, height: TEX_SIZE }, this.scene, false); - baseTex.wrapU = 1; // wrap - baseTex.wrapV = 1; - baseTex.uScale = WORLD_HALF * 2 / 4; // одна повторка покрывает 4 клетки - baseTex.vScale = WORLD_HALF * 2 / 4; - baseTex.updateSamplingMode(DynamicTexture.NEAREST_SAMPLINGMODE); - const ctx = baseTex.getContext(); - // Серая основа - ctx.fillStyle = '#7a8071'; - ctx.fillRect(0, 0, TEX_SIZE, TEX_SIZE); - // Тёмные «рейки» по периметру плитки (Roblox-style) - ctx.strokeStyle = '#5d6358'; - ctx.lineWidth = 4; - ctx.strokeRect(2, 2, TEX_SIZE - 4, TEX_SIZE - 4); - // Тонкая внутренняя сетка 4 на плитке - ctx.strokeStyle = '#6c7268'; - ctx.lineWidth = 1; - for (let i = 1; i < 4; i++) { - const p = (i * TEX_SIZE) / 4; - ctx.beginPath(); - ctx.moveTo(p, 2); ctx.lineTo(p, TEX_SIZE - 2); - ctx.moveTo(2, p); ctx.lineTo(TEX_SIZE - 2, p); - ctx.stroke(); - } - baseTex.update(); - - const mat = new StandardMaterial('groundMat', this.scene); - mat.diffuseTexture = baseTex; - mat.specularColor = new Color3(0, 0, 0); - ground.material = mat; - ground.receiveShadows = true; - - // Только две осевые линии (X и Z), цветные — для ориентации в редакторе. - // Сетку делает сама baseplate-текстура. - const axisMatX = new StandardMaterial('axisMatX', this.scene); - axisMatX.diffuseColor = new Color3(0.85, 0.25, 0.25); - axisMatX.emissiveColor = new Color3(0.5, 0.1, 0.1); - axisMatX.specularColor = new Color3(0, 0, 0); - - const axisMatZ = new StandardMaterial('axisMatZ', this.scene); - axisMatZ.diffuseColor = new Color3(0.25, 0.4, 0.85); - axisMatZ.emissiveColor = new Color3(0.1, 0.2, 0.5); - axisMatZ.specularColor = new Color3(0, 0, 0); - - // Ось X (красная) — линия вдоль X на z=0 - const axisX = MeshBuilder.CreateBox('axisX', - { width: WORLD_HALF * 2, height: 0.02, depth: 0.1 }, this.scene); - axisX.position = new Vector3(0, 0.011, 0); - axisX.material = axisMatX; - axisX.isPickable = false; - this._gridLines = [axisX]; - - // Ось Z (синяя) — линия вдоль Z на x=0 - const axisZ = MeshBuilder.CreateBox('axisZ', - { width: 0.1, height: 0.02, depth: WORLD_HALF * 2 }, this.scene); - axisZ.position = new Vector3(0, 0.011, 0); - axisZ.material = axisMatZ; - axisZ.isPickable = false; - this._gridLines.push(axisZ); - } - - /** - * Изменить размер пола (worldSize × worldSize). Пересоздаёт пол и осевые линии. - * @param {number} worldSize — полный размер стороны пола в юнитах (например 100, 200, 500). - */ - setWorldSize(worldSize) { - const half = Math.max(10, Math.round(worldSize / 2)); - if (half === this._worldHalf) return; - this._worldHalf = half; - // ВАЖНО: physics.floorHalf по умолчанию 40. Если визуальная плита - // больше — игрок проваливается за пределами центрального 80×80 - // квадрата. Синхронизируем физику с визуалом. - if (this.physics) this.physics.floorHalf = half; - // Удалить старый пол + осевые линии - const oldGround = this.scene.getMeshByName('editorGround'); - if (oldGround) try { oldGround.dispose(); } catch (e) { /* ignore */ } - if (Array.isArray(this._gridLines)) { - for (const line of this._gridLines) { - try { line.dispose(); } catch (e) { /* ignore */ } - } - } - this._gridLines = []; - this._createGroundGrid(); - } - - /** Текущий размер пола в юнитах (worldSize, не worldHalf). */ - getWorldSize() { return this._worldHalf * 2; } - - /** Включить/выключить пол (визуально и физически). */ - setFloorEnabled(enabled) { - this._floorEnabled = !!enabled; - if (!this.scene) return; - const ground = this.scene.getMeshByName('editorGround'); - if (ground) ground.setEnabled(this._floorEnabled); - // Линии осей тоже - if (Array.isArray(this._gridLines)) { - for (const line of this._gridLines) { - if (line && line.setEnabled) line.setEnabled(this._floorEnabled); - } - } - // Физика: отключаем коллизию с baseplate, чтобы игрок проваливался - if (this.physics) this.physics.floorEnabled = this._floorEnabled; - } - isFloorEnabled() { return this._floorEnabled !== false; } - - /** - * Очистить гладкий ландшафт (RobloxTerrain) — убирает все chunks, - * отвязывает от физики, возвращает baseplate-пол, ставит spawn по умолчанию. - * Вызывается из UI (кнопка «✖» в Генератор-панели). - */ - clearRobloxTerrain() { - let hadTerrain = false; - if (this._robloxTerrain) { - try { this._robloxTerrain.disposeAll(); hadTerrain = true; } catch (e) {} - // ВАЖНО: обнуляем ссылку, иначе __robloxTest при новой генерации - // решит что terrain уже есть и НЕ переподключит его к physics. - this._robloxTerrain = null; - } - // Декорации тоже чистим (thin-instances очищаются, prototype остаётся - // в памяти для следующего применения). - if (this._smoothDecoManager) { - try { this._smoothDecoManager.clear(); } catch (e) {} - } - this._smoothDecoParams = null; - if (this.physics?.setRobloxTerrain) { - this.physics.setRobloxTerrain(null); - } - if (this.physics?.setSmoothDecoTrees) { - this.physics.setSmoothDecoTrees(null); - } - try { this.setFloorEnabled(true); } catch (e) {} - this._spawnPoint = { x: 0, y: 5, z: 0 }; - try { this._updateSpawnMarker?.(); } catch (e) {} - // Прячем мини-карту гладкого ландшафта — grid больше нет. - if (window.__robloxMinimapGrid) { - window.__robloxMinimapGrid = null; - this._terrainStreamingEnabled = false; - } - // Помечаем dirty чтобы автосейв записал пустой robloxTerrain - try { this._onSceneChange?.(); } catch (e) {} - console.log(`[BabylonScene] clearRobloxTerrain (hadTerrain=${hadTerrain})`); - } - - /** - * Множитель силы прыжка игрока. 1 = базовый (~8 у/с), 1.5 = в 1.5 раза выше. - * Применяется при enterPlayMode и через player.setJumpPower. - */ - setPlayerJumpPower(mul) { - const m = Math.max(0.2, Math.min(5, Number(mul) || 1)); - this._jumpPowerMul = m; - if (this.player) this.player._jumpPowerMul = m; - } - getPlayerJumpPower() { return this._jumpPowerMul ?? 1; } - - /** Тип прицела в Play: 'none' | 'dot' | 'cross' | 'circle'. */ - setCrosshair(type) { - const allowed = ['none', 'dot', 'cross', 'circle']; - if (!allowed.includes(type)) return; - this._crosshair = type; - } - getCrosshair() { return this._crosshair || 'none'; } - - /** - * LOD/culling для моделей: модели дальше LOD_FREEZE замораживают мировую - * матрицу (экономия CPU), модели дальше LOD_CULL — отключаются от рендера. - * Запускается в render-loop из tick(). - */ - _updateModelLOD() { - if (!this.modelManager || !this.camera) return; - const cam = this.camera.position; - const LOD_FREEZE = 60; // юниты — за этим расстоянием freezeWorldMatrix - const LOD_FREEZE_SQ = LOD_FREEZE * LOD_FREEZE; - const LOD_CULL = 600; // юниты — за этим расстоянием полностью скрываем - // (было 200; увеличено чтобы модели на удалённых уровнях не пропадали при editor-камере) - const LOD_CULL_SQ = LOD_CULL * LOD_CULL; - for (const data of this.modelManager.instances.values()) { - const root = data.rootMesh; - if (!root) continue; - // Динамические объекты (зомби, спавнеры, runtime-спавнутые) НЕ - // подвергаем LOD-freeze — за ними двигает свой менеджер. - const gp = data.gameplay; - if (gp?.isZombie || gp?.isZombieSpawner || data._spawnedAtRuntime) continue; - const dx = root.position.x - cam.x; - const dy = root.position.y - cam.y; - const dz = root.position.z - cam.z; - const distSq = dx * dx + dy * dy + dz * dz; - // Cull - const shouldCull = distSq > LOD_CULL_SQ && data.visible !== false; - if (shouldCull && root._kubikonCulled !== true) { - root.setEnabled(false); - root._kubikonCulled = true; - } else if (!shouldCull && root._kubikonCulled === true) { - root.setEnabled(data.visible !== false); - root._kubikonCulled = false; - } - // Freeze - const shouldFreeze = distSq > LOD_FREEZE_SQ; - if (shouldFreeze && root._kubikonFrozen !== true) { - try { root.freezeWorldMatrix(); } catch (e) { /* ignore */ } - root._kubikonFrozen = true; - } else if (!shouldFreeze && root._kubikonFrozen === true) { - try { root.unfreezeWorldMatrix(); } catch (e) { /* ignore */ } - root._kubikonFrozen = false; - } - } - } - - /** - * LOD-cull для примитивов: далёкие декорации скрываем, ближние видны. - * Только в Play-режиме (в редакторе пользователь должен видеть всю сцену - * чтобы редактировать). На больших проектах (Only Up: 568 примитивов на - * вертикальной башне) это критично — без LOD при повороте камеры Babylon - * frustum-cull'ит сотни мешей и FPS падает в пол. - */ - _updatePrimitiveLOD() { - if (!this._isPlaying) return; - if (!this.primitiveManager || !this.camera) return; - const cam = this.camera.position; - const CULL = 120; - const CULL_SQ = CULL * CULL; - for (const data of this.primitiveManager.instances.values()) { - const m = data.mesh; - if (!m) continue; - // Не трогаем явно скрытые/невидимые скриптом - if (data.visible === false) continue; - const dx = data.x - cam.x; - const dy = data.y - cam.y; - const dz = data.z - cam.z; - const distSq = dx * dx + dy * dy + dz * dz; - const shouldCull = distSq > CULL_SQ; - if (shouldCull && m._kubikonPrimCulled !== true) { - m.setEnabled(false); - m._kubikonPrimCulled = true; - } else if (!shouldCull && m._kubikonPrimCulled === true) { - m.setEnabled(true); - m._kubikonPrimCulled = false; - } - } - } - - /** - * Roblox-style input handlers. - * Мышиные события — на canvas (только когда мышь над сценой). - * Клавиатурные — на window (работают при любом фокусе, как в реальных - * 3D-редакторах). Используем e.code (KeyW, KeyA, KeyS, KeyD, KeyQ, KeyE, KeyF) - * чтобы клавиши работали на любой раскладке (русская/английская). - */ - _setupInputControls() { - const canvas = this.canvas; - - // === МЫШЬ === - // mousedown на canvas в capture-фазе → срабатывает первым, - // даже если поверх есть другие listeners. - const onMouseDown = (e) => { - if (this._isPlaying) { - // В Play-режиме ЛКМ — клик игрока в forward-направлении. - // При pointer-lock курсор в центре; в third (свободный курсор) - // передаём реальные координаты клика для pick по табличкам. - if (e.button === 0) { - const r = canvas.getBoundingClientRect(); - this._handlePlayClick(e.clientX - r.left, e.clientY - r.top); - } - return; - } - // Обновляем pointer координаты для raycast и Gizmo - const r = canvas.getBoundingClientRect(); - this.scene.pointerX = e.clientX - r.left; - this.scene.pointerY = e.clientY - r.top; - - // Если это ЛКМ — пробуем pickнуть гизмо. Если попали в гизмо — - // отдаём событие Babylon GizmoManager и выходим (не ставим блок). - // Проверка attachedMesh || attachedNode — у разных версий Babylon - // и при attachToMesh vs attachToNode заполняется разное поле. - const hasAttachment = this._gizmo && - (this._gizmo.manager.attachedMesh || this._gizmo.manager.attachedNode); - if (e.button === 0 && hasAttachment) { - const ulScene = this._gizmoLayer?.utilityLayerScene; - if (ulScene) { - const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY); - if (ulPick && ulPick.hit) { - // Симулируем pointer-events для GizmoManager на utility-scene - ulScene.simulatePointerDown(ulPick); - this._gizmoDragging = true; - e.preventDefault(); - return; - } - } - } - - // Запоминаем стартовую точку любого нажатия — для drag-detection. - this._mouseDownButton = e.button; - this._mouseDownX = e.clientX; - this._mouseDownY = e.clientY; - this._mouseDownTime = Date.now(); - - // ЛКМ + tool=block/erase → активируем drag-постановку. - // Сразу же ставим первый блок в клетке под курсором. - if (e.button === 0 && !e.shiftKey - && (this._activeTool === 'block' || this._activeTool === 'erase')) { - this._isDragPlacing = true; - this._lastPlacedKey = null; - this._dragLockAxis = null; - this._dragPlaceTick(false, /*isFirst*/ true); - e.preventDefault(); - } - // ЛКМ + tool=terrain → активируем drag-кисть террейна. - // Shift модификатор обрабатывается внутри _terrainBrushTick (стирание). - else if (e.button === 0 && this._activeTool === 'terrain') { - this._isTerrainBrushing = true; - this._terrainDragLockY = null; - this._terrainHistoryOpen(); // снапшот для undo - this._setTerrainBrushPreviewActive(true); - this._terrainBrushTick(e.shiftKey, /*isFirst*/ true); - e.preventDefault(); - } - // Shift+ЛКМ — drag-удаление (даже если tool=block) - else if (e.button === 0 && e.shiftKey) { - this._isDragPlacing = true; - this._lastPlacedKey = null; - this._dragLockAxis = null; - this._dragPlaceTick(true, /*isFirst*/ true); - e.preventDefault(); - } - - if (e.button === 2) { - this._isRotating = true; - this._lastMouseX = e.clientX; - this._lastMouseY = e.clientY; - canvas.style.cursor = 'grabbing'; - e.preventDefault(); - e.stopPropagation(); - } else if (e.button === 1) { - this._isPanning = true; - this._lastMouseX = e.clientX; - this._lastMouseY = e.clientY; - canvas.style.cursor = 'move'; - e.preventDefault(); - e.stopPropagation(); - } - // ЛКМ (button === 0) ничего не запускает сразу — обрабатывается на mouseup - // только если был "клик" (не drag). - }; - - // mouseup и mousemove — на window, чтобы drag работал даже когда - // курсор вышел за пределы canvas (стандартное поведение для drag). - const onMouseMove = (e) => { - // Babylon без detachControl сам не пишет в scene.pointerX/Y — - // делаем это руками. Нужны для raycast (scene.pick) и для гизмо. - const r = canvas.getBoundingClientRect(); - this.scene.pointerX = e.clientX - r.left; - this.scene.pointerY = e.clientY - r.top; - - // Если идёт drag гизмо — проксируем move в utility-scene - if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) { - const ulScene = this._gizmoLayer.utilityLayerScene; - const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY); - ulScene.simulatePointerMove(ulPick); - return; - } - - // Если идёт drag-постановка блоков — пытаемся поставить в новой клетке - if (this._isDragPlacing) { - this._dragPlaceTick(e.shiftKey); - return; - } - - // Если идёт drag-кисть террейна — продолжаем рисовать. - // Двигаем preview-меш под курсор ВНУТРИ drag тоже, иначе сфера - // зависает в точке первого клика, пока юзер водит мышью. - if (this._isTerrainBrushing) { - this._terrainBrushTick(e.shiftKey, /*isFirst*/ false); - this._updateTerrainBrushPosition(); - return; - } - - // Когда tool=terrain без drag'а — подвигаем preview-меш под курсор - if (this._activeTool === 'terrain') { - this._updateTerrainBrushPosition(); - } - - if (!this._isRotating && !this._isPanning) return; - const dx = e.clientX - this._lastMouseX; - const dy = e.clientY - this._lastMouseY; - this._lastMouseX = e.clientX; - this._lastMouseY = e.clientY; - - if (this._isRotating) { - this.camera.rotation.y += dx * this.ROTATE_SENSITIVITY; - this.camera.rotation.x += dy * this.ROTATE_SENSITIVITY; - const limit = Math.PI / 2 - 0.05; - if (this.camera.rotation.x > limit) this.camera.rotation.x = limit; - if (this.camera.rotation.x < -limit) this.camera.rotation.x = -limit; - } else if (this._isPanning) { - const right = this._getCameraRight(); - const up = this._getCameraUp(); - this.camera.position.addInPlace(right.scale(-dx * this.PAN_SENSITIVITY)); - this.camera.position.addInPlace(up.scale(dy * this.PAN_SENSITIVITY)); - } - }; - - const onMouseUp = (e) => { - // Если идёт drag гизмо — отдаём pointerup и завершаем - if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) { - const ulScene = this._gizmoLayer.utilityLayerScene; - const r = canvas.getBoundingClientRect(); - this.scene.pointerX = e.clientX - r.left; - this.scene.pointerY = e.clientY - r.top; - const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY); - ulScene.simulatePointerUp(ulPick); - this._gizmoDragging = false; - this._mouseDownButton = -1; - return; - } - // Если был drag-кисть террейна — сбрасываем флаг - if (this._isTerrainBrushing) { - this._isTerrainBrushing = false; - this._terrainDragLockY = null; - this._smoothBrushLockY = null; - this._smoothBrushLastPos = null; - this._terrainHistoryClose(); // фиксируем undo-снапшот - this._setTerrainBrushPreviewActive(false); - this._mouseDownButton = -1; - return; - } - // Если был drag-place — просто сбрасываем флаг, клик не обрабатываем - // (первая постановка уже сделана при mousedown). - if (this._isDragPlacing) { - this._isDragPlacing = false; - this._lastPlacedKey = null; - this._dragLockAxis = null; - this._mouseDownButton = -1; - return; - } - // Если это была ЛКМ и НЕ drag (курсор не сдвинулся существенно) - // → это клик; обрабатываем как редактор-клик (поставить/удалить блок). - if (e.button === 0 && this._mouseDownButton === 0) { - const dx = Math.abs(e.clientX - this._mouseDownX); - const dy = Math.abs(e.clientY - this._mouseDownY); - const dt = Date.now() - this._mouseDownTime; - if (dx < 4 && dy < 4 && dt < 400) { - this._handleEditorClick(e.shiftKey, e.ctrlKey || e.metaKey); - } - } - this._mouseDownButton = -1; - - if (e.button === 2) { - this._isRotating = false; - canvas.style.cursor = 'default'; - } else if (e.button === 1) { - this._isPanning = false; - canvas.style.cursor = 'default'; - } - }; - - const onWheel = (e) => { - e.preventDefault(); - const forward = this._getCameraForward(); - const delta = -Math.sign(e.deltaY) * this.ZOOM_SPEED; - this.camera.position.addInPlace(forward.scale(delta)); - }; - - const onContextMenu = (e) => { - e.preventDefault(); - }; - - // === КЛАВИАТУРА === - // Используем e.code (KeyW, KeyA, ...) — независимо от раскладки. - // ВАЖНО: e.key на русской раскладке возвращает кириллицу ('ц', 'ы', ...), - // поэтому надёжно использовать только e.code. - - /** - * Игнорировать события клавиатуры если фокус в input/textarea/contenteditable. - * Иначе пробел/буквы из ввода в модалке двигают камеру и блокируют ввод. - * Также игнорируем когда открыта модалка (z-index overlay). - */ - const isTypingTarget = (target) => { - if (!target) return false; - const tag = (target.tagName || '').toLowerCase(); - if (tag === 'input' || tag === 'textarea' || tag === 'select') return true; - if (target.isContentEditable) return true; - return false; - }; - - const onKeyDown = (e) => { - if (isTypingTarget(e.target)) return; - this._codes.add(e.code); - if (e.shiftKey) this._shiftDown = true; - // Маршрутизация game.onKey в Play-режиме - if (this._isPlaying && this.gameRuntime) { - const key = this._normalizeKey(e); - this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code }); - } - if (e.code === 'KeyF') { - this._focusOnTarget(new Vector3(0, 0, 0)); - } - // Ctrl+D — дублировать выделенное - if (e.code === 'KeyD' && (e.ctrlKey || e.metaKey) && !this._isPlaying) { - e.preventDefault(); - this.duplicateSelected(); - return; - } - // Ctrl+C — копировать выделенное в буфер (Фаза 5.10). - if (e.code === 'KeyC' && (e.ctrlKey || e.metaKey) && !this._isPlaying) { - e.preventDefault(); - this.copySelected(); - return; - } - // Ctrl+V — вставить из буфера (работает и между проектами). - if (e.code === 'KeyV' && (e.ctrlKey || e.metaKey) && !this._isPlaying) { - e.preventDefault(); - this.pasteFromClipboard(); - return; - } - // Ctrl+Z — undo, Ctrl+Shift+Z или Ctrl+Y — redo - if ((e.ctrlKey || e.metaKey) && !this._isPlaying) { - if (e.code === 'KeyZ' && !e.shiftKey) { - e.preventDefault(); - this.undo?.(); - return; - } - if ((e.code === 'KeyZ' && e.shiftKey) || e.code === 'KeyY') { - e.preventDefault(); - this.redo?.(); - return; - } - // Ctrl+G — сгруппировать выделенное в новую папку - if (e.code === 'KeyG') { - e.preventDefault(); - this.groupSelected(); - return; - } - // Ctrl+A — выделить всё - if (e.code === 'KeyA') { - e.preventDefault(); - this.selection?.selectAll(); - return; - } - } - - // R — повернуть ghost-модель на 90° (или выделенную модель) - if (e.code === 'KeyR' && !this._isPlaying) { - const sel = this.selection?.getSelection(); - if (sel?.type === 'model') { - const newAngle = (sel.rotationY || 0) + Math.PI / 2; - this.selection.rotateSelectedModel(newAngle); - } else if (this._activeTool === 'model') { - this._ghostRotationY = (this._ghostRotationY + Math.PI / 2) % (Math.PI * 2); - } - } - // Delete / Backspace — удалить выделенный - if ((e.code === 'Delete' || e.code === 'Backspace') && !this._isPlaying) { - // Приоритет: выбранная инструментом «Выбрать деко» декорация. - if (this._decoSelection) { - this._deleteSelectedDeco(); - e.preventDefault(); - } else if (this.selection?.getSelection()) { - this.selection.deleteSelected(); - e.preventDefault(); - } - } - // Escape — снять выделение + переключиться на инструмент «Выделить» - // (в режиме игры Esc обрабатывает PlayerController — выход из Play). - if (e.code === 'Escape' && !this._isPlaying) { - this.selection?.clear(); - if (this._onEditorEscape) { - try { this._onEditorEscape(); } catch (err) { /* ignore */ } - } - } - if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) { - e.preventDefault(); - } - }; - - const onKeyUp = (e) => { - if (isTypingTarget(e.target)) return; - this._codes.delete(e.code); - if (!e.shiftKey) this._shiftDown = false; - if (this._isPlaying && this.gameRuntime) { - const key = this._normalizeKey(e); - this.gameRuntime.routeGlobalEvent('keyup', { key, code: e.code }); - } - }; - - const onBlur = () => { - this._codes.clear(); - this._shiftDown = false; - this._isRotating = false; - this._isPanning = false; - canvas.style.cursor = 'default'; - }; - - // Регистрация: - // - mousedown/move/up на CANVAS в capture-фазе. Это самое надёжное место - // для перехвата мыши над сценой; наш обработчик отрабатывает первым, - // до Babylon-овских стандартных listeners. - // - keydown/keyup — на window (клавиатуру всегда слушаем глобально). - // - wheel/contextmenu — на canvas в capture. - canvas.addEventListener('mousedown', onMouseDown, true); - canvas.addEventListener('wheel', onWheel, { passive: false, capture: true }); - canvas.addEventListener('contextmenu', onContextMenu, true); - // mousemove/mouseup на window — для drag за пределами canvas. - window.addEventListener('mousemove', onMouseMove); - window.addEventListener('mouseup', onMouseUp); - window.addEventListener('keydown', onKeyDown); - window.addEventListener('keyup', onKeyUp); - window.addEventListener('blur', onBlur); - - this._listeners = [ - { target: canvas, type: 'mousedown', fn: onMouseDown, opts: true }, - { target: canvas, type: 'wheel', fn: onWheel, opts: { capture: true } }, - { target: canvas, type: 'contextmenu', fn: onContextMenu, opts: true }, - { target: window, type: 'mousemove', fn: onMouseMove }, - { target: window, type: 'mouseup', fn: onMouseUp }, - { target: window, type: 'keydown', fn: onKeyDown }, - { target: window, type: 'keyup', fn: onKeyUp }, - { target: window, type: 'blur', fn: onBlur }, - ]; - } - - /** - * Двигаем камеру по WASDQE — работает всегда, не требует зажатой ПКМ - * (Minecraft Creative-style — удобнее чем Roblox для редактирования сцены). - * ПКМ нужна только для поворота камеры. - * Вызывается каждый кадр из render loop. - * Используем e.code — независимо от раскладки клавиатуры. - */ - _updateCameraMovement() { - if (this._isPlaying) return; // в режиме игры редактор-камера не движется - const c = this._codes; - if (c.size === 0) return; - - const dt = this.engine.getDeltaTime() / 1000; - const speed = this.MOVE_SPEED * dt * (this._shiftDown ? this.SHIFT_MULTIPLIER : 1); - - const forward = this._getCameraForward(); - const right = this._getCameraRight(); - const worldUp = new Vector3(0, 1, 0); - const move = Vector3.Zero(); - - if (c.has('KeyW') || c.has('ArrowUp')) move.addInPlace(forward.scale(speed)); - if (c.has('KeyS') || c.has('ArrowDown')) move.addInPlace(forward.scale(-speed)); - if (c.has('KeyD') || c.has('ArrowRight')) move.addInPlace(right.scale(speed)); - if (c.has('KeyA') || c.has('ArrowLeft')) move.addInPlace(right.scale(-speed)); - if (c.has('KeyE') || c.has('Space')) move.addInPlace(worldUp.scale(speed)); - if (c.has('KeyQ')) move.addInPlace(worldUp.scale(-speed)); - - if (move.lengthSquared() > 0) { - this.camera.position.addInPlace(move); - } - } - - /** - * Единичный вектор «вперёд» камеры (с учётом её поворота). - */ - _getCameraForward() { - const yaw = this.camera.rotation.y; - const pitch = this.camera.rotation.x; - return new Vector3( - Math.sin(yaw) * Math.cos(pitch), - -Math.sin(pitch), - Math.cos(yaw) * Math.cos(pitch) - ).normalize(); - } - - /** - * Единичный вектор «вправо» камеры (перпендикуляр forward в горизонтальной плоскости). - */ - _getCameraRight() { - const yaw = this.camera.rotation.y; - return new Vector3(Math.cos(yaw), 0, -Math.sin(yaw)).normalize(); - } - - /** - * Единичный вектор «вверх» относительно камеры. - */ - _getCameraUp() { - const forward = this._getCameraForward(); - const right = this._getCameraRight(); - return Vector3.Cross(right, forward).normalize(); - } - - /** - * Фокус на точке: ставим камеру в 15 единицах от target по текущему направлению. - * Будет использоваться для F — focus on selected. - */ - _focusOnTarget(target) { - const offset = this._getCameraForward().scale(-15); - this.camera.position = target.add(offset); - this.camera.setTarget(target); - } - - // === ИНСТРУМЕНТЫ И БЛОКИ =========================================== - - /** - * Создать "призрачный" блок — полупрозрачный преview, показывает где - * появится блок при клике. - */ - _createGhostBlock() { - const ghost = MeshBuilder.CreateBox('ghostBlock', { size: 1.02 }, this.scene); - const mat = new StandardMaterial('ghostMat', this.scene); - mat.diffuseColor = new Color3(0.4, 0.9, 0.4); - mat.alpha = 0.35; - mat.specularColor = new Color3(0, 0, 0); - mat.disableLighting = true; - ghost.material = mat; - ghost.isPickable = false; // raycast его игнорирует - ghost.setEnabled(false); - this._ghostMesh = ghost; - } - - /** - * Создать видимый маркер точки спавна — полупрозрачный жёлтый цилиндр со - * светящейся вершиной. Виден только в редакторе, скрывается в Play. - */ - _createSpawnMarker() { - // Базовый цилиндр-подставка - const base = MeshBuilder.CreateCylinder( - 'spawnMarkerBase', - { diameterTop: 1.0, diameterBottom: 1.2, height: 0.15, tessellation: 24 }, - this.scene - ); - const baseMat = new StandardMaterial('spawnBaseMat', this.scene); - baseMat.diffuseColor = new Color3(0.95, 0.75, 0.2); - baseMat.emissiveColor = new Color3(0.3, 0.2, 0); - baseMat.specularColor = new Color3(0, 0, 0); - baseMat.alpha = 0.85; - base.material = baseMat; - - // Внутренний светящийся столб - const beam = MeshBuilder.CreateCylinder( - 'spawnMarkerBeam', - { diameter: 0.4, height: 2.5, tessellation: 16 }, - this.scene - ); - const beamMat = new StandardMaterial('spawnBeamMat', this.scene); - beamMat.diffuseColor = new Color3(1, 0.9, 0.3); - beamMat.emissiveColor = new Color3(1, 0.85, 0.2); - beamMat.specularColor = new Color3(0, 0, 0); - beamMat.alpha = 0.4; - beamMat.disableLighting = true; - beam.material = beamMat; - beam.position.y = 1.3; - - // Группируем base+beam в TransformNode чтобы двигать как одно - const root = new TransformNode('spawnMarker', this.scene); - base.parent = root; - beam.parent = root; - root.position = new Vector3(this._spawnPoint.x, this._spawnPoint.y, this._spawnPoint.z); - - // Делаем маркер pickable, чтобы можно было кликнуть и выделить. - // Метаданные для SelectionManager: { isSpawn: true }. - base.isPickable = true; - beam.isPickable = true; - base.metadata = { isSpawn: true }; - beam.metadata = { isSpawn: true }; - - this._spawnMarker = root; - this._spawnMarkerMeshes = [base, beam]; - } - - /** Обновить позицию визуального маркера спавна. */ - _updateSpawnMarker() { - if (!this._spawnMarker) return; - this._spawnMarker.position.set( - this._spawnPoint.x, - this._spawnPoint.y, - this._spawnPoint.z - ); - } - - /** Скрыть/показать маркер спавна. */ - _setSpawnMarkerVisible(visible) { - if (!this._spawnMarker) return; - this._spawnMarker.setEnabled(visible); - // КРИТИЧНО: при скрытии маркера в Play также делаем его непикаемым. - // Babylon `pickWithRay` ловит меши даже при `setEnabled(false)` если - // disabled у parent TransformNode. Без isPickable=false луч стрельбы - // попадает в столб маркера в 5м перед игроком. - if (this._spawnMarkerMeshes) { - for (const m of this._spawnMarkerMeshes) { - if (m) m.isPickable = visible; - } - } - } - - /** - * Raycast от курсора в сцену. - * Возвращает { mesh, point, normal } либо null если ни во что не попали. - * Игнорирует ghost-блок и линии сетки. - */ - /** - * Нормализация клавиши из KeyboardEvent в простую строку для game.onKey. - * KeyW → 'w', Space → 'space', ArrowUp → 'arrowup', ShiftLeft → 'shift', ... - */ - _normalizeKey(e) { - const code = e.code || ''; - // Буквы KeyA..KeyZ → 'a'..'z' - if (/^Key[A-Z]$/.test(code)) return code.charAt(3).toLowerCase(); - // Цифры Digit0..Digit9 → '0'..'9' - if (/^Digit\d$/.test(code)) return code.charAt(5); - // Спецклавиши - const map = { - Space: 'space', - Enter: 'enter', - NumpadEnter: 'enter', - Escape: 'escape', - Tab: 'tab', - Backspace: 'backspace', - ShiftLeft: 'shift', ShiftRight: 'shift', - ControlLeft: 'ctrl', ControlRight: 'ctrl', - AltLeft: 'alt', AltRight: 'alt', - ArrowUp: 'arrowup', ArrowDown: 'arrowdown', - ArrowLeft: 'arrowleft', ArrowRight: 'arrowright', - }; - if (map[code]) return map[code]; - // Fallback — сам key в lower-case - return String(e.key || code).toLowerCase(); - } - - /** - * Pick по центру экрана (для Play-режима где курсор залочен). - * Используется для game.self.onClick — клик луч-форвард игрока. - */ - _pickFromCenter() { - const w = this.engine?.getRenderWidth?.() || this.canvas.width; - const h = this.engine?.getRenderHeight?.() || this.canvas.height; - const pi = this.scene.pick(w / 2, h / 2, (mesh) => { - if (!mesh.isPickable) return false; - if (mesh === this._ghostMesh) return false; - if (mesh.name && mesh.name.startsWith('gridLine')) return false; - return true; - }); - if (!pi || !pi.hit) return null; - let mesh = pi.pickedMesh; - if (mesh?.metadata?._isBlockProto && this.blockManager) { - const proxy = this.blockManager.findProxyByPickInfo(pi); - if (proxy) mesh = proxy; - } - return { mesh, point: pi.pickedPoint, pickInfo: pi }; - } - - /** - * Извлечь target {kind, ref} из mesh (proxy/прим/модель). - * Используется при клике/touch в Play. - */ - _meshToTarget(mesh) { - if (!mesh || !mesh.metadata) return null; - const md = mesh.metadata; - if (md.isBlock) { - return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } }; - } - if (md.isModel) return { kind: 'model', id: md.instanceId }; - if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId }; - return null; - } - - /** - * Детекция касания игроком объектов с target-скриптами. - * Для каждого target-скрипта проверяем AABB-overlap с игроком. - * Событие 'touch' эмитится один раз на «вход» (на rising edge) — пока - * игрок не выйдет из объекта и не вернётся, повторно touch не вызывается. - */ - _detectTouchEvents() { - const rt = this.gameRuntime; - if (!rt || !this.player?._pos) return; - const scripts = this._scripts || []; - if (scripts.length === 0) return; - // Кэш «контакта»: scriptId → true если сейчас касается - if (!this._touchState) this._touchState = new Map(); - const seen = new Set(); - const px = this.player._pos.x; - const py = this.player._pos.y; // центр капсулы - const pz = this.player._pos.z; - const phw = this.player.HALF_W ?? 0.3; - const phh = this.player.HALF_H ?? 0.9; - const phd = this.player.HALF_D ?? 0.3; - - // EPS — допуск касания. Когда игрок СТОИТ на объекте сверху, - // низ его капсулы строго совпадает с верхом объекта (зазор 0), - // и строгое сравнение AABB даёт «не пересекаются». Расширяем - // зону на EPS, чтобы «стоит на объекте/вплотную» = касание. - // Без этого onTouch финиша/плитки не срабатывает (игрок встал). - const EPS = 0.25; - - // 1) Касания объектов с target-скриптами (ключ touchState = 's:'+scriptId) - for (const s of scripts) { - if (!s.target) continue; - const key = 's:' + s.id; - seen.add(key); - const aabb = this._targetAABB(s.target); - if (!aabb) continue; - const overlap = - px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS && - py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS && - pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS; - const wasTouching = this._touchState.get(key); - if (overlap && !wasTouching) { - this._touchState.set(key, true); - rt.routeEvent(s.target, 'touch', {}); - rt.routeGlobalEvent('playerTouch', { target: s.target }); - } else if (!overlap && wasTouching) { - this._touchState.set(key, false); - rt.routeEvent(s.target, 'untouch', {}); - } - } - - // 2) Касания примитивов-триггеров (type === 'trigger') БЕЗ скрипта — - // шлём глобальное playerTouch с target. Это позволяет писать - // логику чек-поинтов в одном глобальном скрипте без скриптов на каждом - // триггере. Ключ: 'p:'+id, чтобы не пересекаться со скриптами. - // Сюда же — примитивы, заспавненные скриптом (data._scriptSpawned): - // для них тоже шлём playerTouch, чтобы игры «поймай объект» - // могли ловить падающие кубы через game.onPlayerTouch. - const prims = this.primitiveManager?.instances; - if (prims && prims.size > 0) { - for (const data of prims.values()) { - const isTrigger = data?.type === 'trigger'; - const isSpawned = data?._scriptSpawned === true; - if (!isTrigger && !isSpawned) continue; - const id = data.id; - // Если на этот примитив УЖЕ повешен target-скрипт — он - // обработан в блоке выше, чтобы не дублировать события. - const hasScript = scripts.some(s => - s.target?.kind === 'primitive' && (s.target.id ?? s.target.ref) === id - ); - if (hasScript) continue; - const key = 'p:' + id; - seen.add(key); - const hx = data.sx / 2, hy = data.sy / 2, hz = data.sz / 2; - const overlap = - px + phw > data.x - hx - EPS && px - phw < data.x + hx + EPS && - py + phh > data.y - hy - EPS && py - phh < data.y + hy + EPS && - pz + phd > data.z - hz - EPS && pz - phd < data.z + hz + EPS; - const wasTouching = this._touchState.get(key); - if (overlap && !wasTouching) { - this._touchState.set(key, true); - // target — строка-ref 'primitive:': её можно - // передать в game.scene.delete и сравнивать. - rt.routeGlobalEvent('playerTouch', { - target: 'primitive:' + id, - }); - } else if (!overlap && wasTouching) { - this._touchState.set(key, false); - } - } - } - - // 3) Касания объектов, на которые подписан ГЛОБАЛЬНЫЙ скрипт через - // findOne(x).onTouch(...) (rt._watchedTouchRefs). Объекты без скрипта - // и не триггеры — например цели туториала. Событие адресное (по ref). - const watched = rt._watchedTouchRefs; - if (watched && watched.size > 0) { - for (const ref of watched) { - const target = this._refToTarget(ref); - if (!target) continue; - const aabb = this._targetAABB(target); - if (!aabb) continue; - const key = 'w:' + ref; - seen.add(key); - const overlap = - px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS && - py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS && - pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS; - const wasTouching = this._touchState.get(key); - if (overlap && !wasTouching) { - this._touchState.set(key, true); - rt.routeInstEvent(ref, 'instTouch', {}); - } else if (!overlap && wasTouching) { - this._touchState.set(key, false); - rt.routeInstEvent(ref, 'instUntouch', {}); - } - } - } - - // Чистим устаревшие записи (удалённые скрипты/триггеры) - for (const id of this._touchState.keys()) { - if (!seen.has(id)) this._touchState.delete(id); - } - } - - /** Ref-строка 'primitive:NN' | 'model:NN' → {kind,id} для _targetAABB. */ - _refToTarget(ref) { - if (typeof ref !== 'string') return null; - const colon = ref.indexOf(':'); - if (colon < 0) return null; - const kind = ref.slice(0, colon); - const rest = ref.slice(colon + 1); - if (kind === 'primitive') { - const id = this.gameRuntime?._resolvePrimitiveId - ? this.gameRuntime._resolvePrimitiveId(rest) - : (Number.isFinite(Number(rest)) ? Number(rest) : rest); - return { kind: 'primitive', id }; - } - if (kind === 'model') { - const n = Number(rest); - return { kind: 'model', id: Number.isFinite(n) ? n : rest }; - } - return null; - } - - /** Получить мировой AABB target-объекта (для touch-детекции). */ - _targetAABB(target) { - if (!target) return null; - try { - if (target.kind === 'block') { - const r = target.ref || target; - return { - minX: r.x - 0.5, maxX: r.x + 0.5, - minY: r.y, maxY: r.y + 1, - minZ: r.z - 0.5, maxZ: r.z + 0.5, - }; - } - if (target.kind === 'model') { - const id = target.id ?? target.ref; - return this.modelManager?.getInstanceAABB?.(id) || null; - } - if (target.kind === 'primitive') { - const id = target.id ?? target.ref; - const data = this.primitiveManager?.instances?.get(id); - if (!data) return null; - const hx = data.sx / 2, hy = data.sy / 2, hz = data.sz / 2; - return { - minX: data.x - hx, maxX: data.x + hx, - minY: data.y - hy, maxY: data.y + hy, - minZ: data.z - hz, maxZ: data.z + hz, - }; - } - } catch (e) { /* ignore */ } - return null; - } - - /** - * Обработка клика в Play-режиме. - * Делает forward-pick и роутит click-событие: - * - в self-обработчики скриптов (routeEvent с target) - * - в глобальные обработчики (game.onClick) с event.target - */ - _handlePlayClick(clickX, clickY) { - if (!this._isPlaying) return; - - // Мультиплеер-выстрел: если у сцены есть mpSync, шлём 'shoot' серверу. - // Используем forward-вектор игрока (XZ-плоскость) — куда смотрит, - // туда и выстрел. На сервере дальше идёт raycast по другим игрокам. - if (this._mpSync && this.player?._pos) { - try { - const yaw = this.player._yaw || 0; - // forward в плоскости XZ: yaw=0 — смотрим в +Z - const dirX = Math.sin(yaw); - const dirZ = Math.cos(yaw); - this._mpSync.sendShoot( - this.player._pos.x, - this.player._pos.z, - dirX, dirZ, - ); - } catch (e) { /* room closed / mpSync disposed */ } - } - - if (!this.gameRuntime) return; - - // === Задача 01: клик по КНОПКЕ 3D-таблички (billboard) === - // При pointer-lock (first/shift-lock) курсор в центре экрана → пикаем - // из центра. В third курсор СВОБОДНЫЙ → пикаем по реальным координатам - // клика (clickX/clickY переданы из onMouseDown). Без этого клик по - // табличке мышью в third промахивался — кнопки не нажимались. - if (this.billboardUiManager && this.primitiveManager) { - const locked = (document.pointerLockElement === this.canvas); - const w = this.engine?.getRenderWidth?.() || this.canvas.width; - const h = this.engine?.getRenderHeight?.() || this.canvas.height; - const px = locked ? w / 2 : (Number.isFinite(clickX) ? clickX : w / 2); - const py = locked ? h / 2 : (Number.isFinite(clickY) ? clickY : h / 2); - const bpick = this.scene.pick(px, py, (m) => - m && m.metadata && m.metadata.primitiveId != null - && this.primitiveManager.instances.get(m.metadata.primitiveId)?.type === 'billboard'); - if (bpick && bpick.hit && bpick.pickedMesh) { - const bdata = this.primitiveManager.instances.get(bpick.pickedMesh.metadata.primitiveId); - const uv = bpick.getTextureCoordinates ? bpick.getTextureCoordinates() : null; - if (bdata && uv) { - const buttonId = this.billboardUiManager.pickButtonAt(bdata, uv.x, uv.y); - console.log('[billboard] клик id=' + bpick.pickedMesh.metadata.primitiveId - + ' uv=(' + uv.x.toFixed(2) + ',' + uv.y.toFixed(2) + ') buttonId=' + buttonId - + ' locked=' + locked); - if (buttonId) { - this.billboardUiManager.fireClick(bdata, buttonId); - return; // клик по табличке обработан - } - } else { - console.log('[billboard] попал в табличку id=' - + bpick.pickedMesh.metadata.primitiveId + ' но нет UV'); - } - } - } - - 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). - try { - const active = this.inventory?.getActive?.(); - if (active) { - this.gameRuntime.routeGlobalEvent('toolUse', { - tool: { - kind: active.kind, - modelTypeId: active.modelTypeId, - name: active.name, - }, - point, target, - }); - } - } catch (e) { /* ignore */ } - } - - /** - * Установить мультиплеер-синхронизатор. Вызывается из KubikonPlayer - * после joinOrCreate. При null — отключаем мультиплеер. - */ - /** Тач-режим — управление через виртуальный джойстик и тач-свайп камеры. - * Должно вызываться ДО enterPlayMode, иначе PlayerController создастся - * с дефолтным mouse/keyboard-управлением. */ - setTouchMode(enabled) { - this._touchMode = !!enabled; - // Если уже в Play и есть player — пробрасываем - if (this.player && typeof this.player.setTouchMode === 'function') { - this.player.setTouchMode(this._touchMode); - } - // На тач-устройствах (= мобила/планшет) включаем low-perf-режим - // автоматически: уменьшаем разрешение рендера, отключаем тени, - // увеличиваем dt физики. Это даёт ×2-3 прирост FPS. - if (this._touchMode) this.applyLowPerfMode(); - } - - /** - * Включить «лёгкий» режим рендера для слабых устройств / мобилок. - * Можно вызвать вручную и на десктопе если FPS проседает. - * - * Главная идея: не пикселим картинку, а уменьшаем нагрузку безболезненно: - * 1. DPR-нормализация: рендерим в DPR=1 (а не ×2-3 как Retina по умолчанию). - * Это даёт х2-х9 буст FPS без видимой потери качества — глаз - * телефонного экрана не различает разницу между DPR=2 и DPR=1. - * В отличие от scalingLevel=2 (рендер в половину родного), здесь - * текстуры остаются чёткими — рендерим в реальное число css-пикселей. - * 2. Отключаем тени — ShadowGenerator самый дорогой эффект. - * 3. skipPointerMovePicking — не делаем raycast от мыши на каждый move. - * 4. maxZ=200 — урезаем дальность рендера. - * - * НЕ делаем (после теста — слишком ухудшало картинку и плавность): - * - hardwareScalingLevel > 1 (давало пикселизацию текстур) - * - FPS-cap 30 через frame-skip (давало дёрганье движения) - * - ZombieManager тик через кадр (тоже дёрганье) - */ - applyLowPerfMode() { - if (this._lowPerfApplied) return; - this._lowPerfApplied = true; - // НЕ трогаем hardwareScalingLevel — оставляем нативное разрешение - // экрана (включая DPR). На современных телефонах GPU справляется, - // а текстуры и текст ника остаются чёткими. Прирост FPS даём за - // счёт отключённых теней / AA / maxZ=200, а не уменьшения буфера. - // Тени — выключаем - try { - if (this._shadowGenerator) { - this._shadowGenerator.dispose(); - this._shadowGenerator = null; - } - this._shadowQuality = 'off'; - } catch (e) { /* ignore */ } - // Скип pointer-move picking — каждый кадр не делаем raycast от мыши - try { this.scene.skipPointerMovePicking = true; } catch (e) {} - // НЕ включаем blockMaterialDirtyMechanism — он ломает свойства - // материалов трейсеров/дебриса (создаются после старта, шейдер не - // пересчитывается, emissiveColor/alpha/disableLighting не применяются). - try { - if (this.scene) { - this.scene.autoClear = true; - this.scene.autoClearDepthAndStencil = true; - } - } catch (e) {} - // Уменьшаем дальность рендера если камера далеко смотрит - try { - if (this.camera && this.camera.maxZ) { - this.camera.maxZ = Math.min(this.camera.maxZ, 200); - } - } catch (e) {} - // FPS-cap НЕ ставим — лучше нативные 60 FPS если устройство тянет. - this._lowPerfFrameSkip = false; - // eslint-disable-next-line no-console - console.log('[BabylonScene] low-perf mode applied (DPR-normalized, no shadows, no frame-skip)'); - } - - setMultiplayerSync(sync) { - this._mpSync = sync; - // Сразу шлём текущее активное оружие, чтобы remote-клиенты - // увидели его в руке модели сразу после нашего onAdd. - if (sync) { - try { - const active = this.inventory?.getActive?.(); - const modelId = (active && active.kind === 'weapon') - ? (active.modelTypeId || '') - : ''; - sync.sendWeapon(modelId); - } catch (e) { /* ignore */ } - } - } - - _pickFromMouse() { - // 1) Стандартный pick — для моделей, примитивов, пола, ghost'ов и т.п. - // Блоки рисуются через thin-instances; их proto-меш ИГНОРИРУЕМ - // в этом проходе (он бы вернул thin instance с потерянным индексом - // в новых версиях Babylon — старая боль с выделением и постановкой). - const pi = this.scene.pick( - this.scene.pointerX, - this.scene.pointerY, - (mesh) => { - if (!mesh.isPickable) return false; - if (mesh === this._ghostMesh) return false; - if (mesh.name && mesh.name.startsWith('gridLine')) return false; - if (mesh.metadata?._isBlockProto) return false; // ⬅ важно! - return true; - } - ); - - // 2) Отдельный пик блоков через свой raycast по AABB-сетке. - // Гораздо надёжнее thin-instance pick'а: даём гарантированный - // proxy + нормаль грани попадания. - const blockHit = this._pickBlockManually(); - - // Выбираем ближайший: либо стандартный pick, либо блок. - if (pi && pi.hit && blockHit) { - // Сравниваем по дистанции от камеры - const cam = this.scene.activeCamera?.position; - if (cam) { - const d1Sq = (pi.pickedPoint.x - cam.x) ** 2 - + (pi.pickedPoint.y - cam.y) ** 2 - + (pi.pickedPoint.z - cam.z) ** 2; - const d2Sq = (blockHit.point.x - cam.x) ** 2 - + (blockHit.point.y - cam.y) ** 2 - + (blockHit.point.z - cam.z) ** 2; - if (d2Sq < d1Sq) { - return blockHit; - } - } - return { - mesh: pi.pickedMesh, - point: pi.pickedPoint, - normal: pi.getNormal(true), - pickInfo: pi, - }; - } - if (blockHit) return blockHit; - if (pi && pi.hit) { - return { - mesh: pi.pickedMesh, - point: pi.pickedPoint, - normal: pi.getNormal(true), - pickInfo: pi, - }; - } - return null; - } - - /** - * Свой raycast по блокам. Идёт от камеры в направлении курсора, проходит - * по сетке и проверяет каждую клетку: есть ли блок в blockManager.blocks? - * Возвращает { mesh: proxy, point, normal } или null. - * - * Используется DDA (digital differential analyzer) — самый быстрый алгоритм - * для voxel-raycast. - */ - _pickBlockManually() { - if (!this.blockManager || !this.scene.activeCamera) return null; - // Получаем ray из курсора - const camera = this.scene.activeCamera; - const ray = this.scene.createPickingRay( - this.scene.pointerX, this.scene.pointerY, null, camera - ); - const origin = ray.origin; - const dir = ray.direction; - - // DDA для voxel-сетки. - // Стартуем с клетки в которой находится origin - let x = Math.round(origin.x); - let y = Math.floor(origin.y); - let z = Math.round(origin.z); - - // Шаги по каждой оси - const stepX = dir.x > 0 ? 1 : -1; - const stepY = dir.y > 0 ? 1 : -1; - const stepZ = dir.z > 0 ? 1 : -1; - - // Длина шага вдоль луча для перехода на следующую клетку - const tDeltaX = Math.abs(dir.x) > 1e-8 ? Math.abs(1 / dir.x) : Infinity; - const tDeltaY = Math.abs(dir.y) > 1e-8 ? Math.abs(1 / dir.y) : Infinity; - const tDeltaZ = Math.abs(dir.z) > 1e-8 ? Math.abs(1 / dir.z) : Infinity; - - // Расстояние до первой границы клетки - // Блок (x,y,z) занимает X: x-0.5..x+0.5, Y: y..y+1, Z: z-0.5..z+0.5 - const nextBoundaryX = x + 0.5 * stepX; - const nextBoundaryY = stepY > 0 ? (y + 1) : y; - const nextBoundaryZ = z + 0.5 * stepZ; - - let tMaxX = Math.abs(dir.x) > 1e-8 ? (nextBoundaryX - origin.x) / dir.x : Infinity; - let tMaxY = Math.abs(dir.y) > 1e-8 ? (nextBoundaryY - origin.y) / dir.y : Infinity; - let tMaxZ = Math.abs(dir.z) > 1e-8 ? (nextBoundaryZ - origin.z) / dir.z : Infinity; - - const MAX_STEPS = 200; // максимум 200 клеток по лучу - const MAX_DIST = 100; // и не дальше 100м - - // Какая ось пересечена последней (для вычисления нормали) - let lastAxis = -1; - - for (let i = 0; i < MAX_STEPS; i++) { - // Проверяем клетку (x, y, z) - if (y >= 0 && y < 200) { - const key = `${x},${y},${z}`; - const proxy = this.blockManager.blocks.get(key); - if (proxy && proxy.metadata?.canCollide !== false) { - // Нашли! Вычисляем точку контакта и нормаль. - let t; - let nx = 0, ny = 0, nz = 0; - if (lastAxis === 0) { - // Зашли через X-грань - t = tMaxX - tDeltaX; - nx = -stepX; - } else if (lastAxis === 1) { - t = tMaxY - tDeltaY; - ny = -stepY; - } else if (lastAxis === 2) { - t = tMaxZ - tDeltaZ; - nz = -stepZ; - } else { - // Стартуем уже внутри клетки — нормаль вверх по умолчанию - t = 0; - ny = 1; - } - if (t > MAX_DIST) return null; - const point = { - x: origin.x + dir.x * t, - y: origin.y + dir.y * t, - z: origin.z + dir.z * t, - }; - return { - mesh: proxy, - point: { x: point.x, y: point.y, z: point.z, clone() { return { x: this.x, y: this.y, z: this.z }; } }, - normal: { x: nx, y: ny, z: nz }, - pickInfo: null, - }; - } - } - // Шаг по ближайшей оси - if (tMaxX < tMaxY && tMaxX < tMaxZ) { - if (tMaxX > MAX_DIST) return null; - x += stepX; - tMaxX += tDeltaX; - lastAxis = 0; - } else if (tMaxY < tMaxZ) { - if (tMaxY > MAX_DIST) return null; - y += stepY; - tMaxY += tDeltaY; - lastAxis = 1; - } else { - if (tMaxZ > MAX_DIST) return null; - z += stepZ; - tMaxZ += tDeltaZ; - lastAxis = 2; - } - } - return null; - } - - - /** - * Обновить позицию ghost-блока под курсором. - * Вызывается каждый кадр когда tool='block'. - */ - _updateGhostPosition() { - if (!this._ghostMesh) return; - if (this._isPlaying) { - this._ghostMesh.setEnabled(false); - return; - } - if (this._activeTool !== 'block' && this._activeTool !== 'model') { - this._ghostMesh.setEnabled(false); - return; - } - const pick = this._pickFromMouse(); - if (!pick) { - this._ghostMesh.setEnabled(false); - return; - } - const target = this._computePlacementCell(pick); - if (!target) { - this._ghostMesh.setEnabled(false); - return; - } - // Не показываем ghost если в этой клетке уже блок (только для tool=block) - if (this._activeTool === 'block' && - this.blockManager?.hasBlock(target.x, target.y, target.z)) { - this._ghostMesh.setEnabled(false); - return; - } - this._ghostMesh.position = new Vector3(target.x, target.y + 0.5, target.z); - // Для модели — отображаем угол поворота через rotation (визуальная подсказка) - if (this._activeTool === 'model') { - this._ghostMesh.rotation.y = this._ghostRotationY; - } else { - this._ghostMesh.rotation.y = 0; - } - this._ghostMesh.setEnabled(true); - } - - /** - * Высчитать целочисленную клетку (gridX, gridY, gridZ) куда ставить блок. - * Координаты — это нижний-передний-левый угол клетки (блок занимает - * (gridX..gridX+1, gridY..gridY+1, gridZ..gridZ+1)). - * - * Попали в блок: новая клетка = соседняя по нормали грани. - * Попали в пол: новая клетка = (round(p.x - 0.5), 0, round(p.z - 0.5)). - * - * Почему -0.5: точка p.x на полу — это координата в мире (0..40). Сетка - * целочисленная: блок «(0,0,0)» занимает (-0.5..0.5, 0..1, -0.5..0.5). - * Чтобы клик точно в центр клетки попал в (0,0,0), нужно округление - * без сдвига. Math.round(0.4) = 0, Math.round(0.6) = 1 — правильно. - */ - _computePlacementCell(pick) { - const p = pick.point; - const n = pick.normal || new Vector3(0, 1, 0); - const mesh = pick.mesh; - - if (mesh?.metadata?.isBlock) { - // Соседняя клетка по нормали грани, в которую попали - const m = mesh.metadata; - const nx = Math.round(n.x); - const ny = Math.round(n.y); - const nz = Math.round(n.z); - const cell = { - x: m.gridX + nx, - y: m.gridY + ny, - z: m.gridZ + nz, - }; - if (cell.y < 0) return null; - return cell; - } - - // Попали в ТЕРРЕЙН (воксельный region-mesh или гладкий roblox-terrain). - // У этих мешей нет metadata.isBlock, но есть свои метки. Берём - // РЕАЛЬНУЮ точку пересечения луча (p.y) — это высота поверхности - // там, куда кликнули. Без этого модель вставала на y=0 (baseplate). - const md = mesh?.metadata; - const isTerrain = md && (md._isTerrainProto || md._isRegionMesh || md._isRobloxTerrain); - if (isTerrain) { - return { - x: Math.round(p.x), - y: p.y, // реальная высота поверхности под курсором - z: Math.round(p.z), - }; - } - - // Попали в пол / прочее. Точка p — мировая. Блок «(ix,iy,iz)» имеет - // центр на (ix, iy+0.5, iz), его горизонтальные грани (x,z) от (ix-0.5) - // до (ix+0.5). Поэтому простое Math.round(p.x) даёт верный gridX. - const x = Math.round(p.x); - const z = Math.round(p.z); - // Если попали в верхнюю грань пола → ставим на y=0. - // Если попали под низ пола (камера ниже сцены) → не ставим. - if (n.y < 0.5) return null; - return { x, y: 0, z }; - } - - /** - * Обработать клик мыши (вызывается из mouseup если это был клик, не drag). - * tool: 'block' / 'model' / 'erase' / 'select'. - */ - _handleEditorClick(shiftKey, ctrlKey = false) { - if (this._isPlaying) return; - if (!this.blockManager) return; - const pick = this._pickFromMouse(); - if (!pick) { - if (this._activeTool === 'select' && !ctrlKey) { - this.selection?.clear(); - } - return; - } - - const tool = shiftKey ? 'erase' : this._activeTool; - - if (tool === 'select') { - if (this.selection) { - // Для надёжности: если pick.mesh почему-то остался прото-мешем - // (без metadata.isBlock), пробуем разрезолвить через - // findProxyByPickInfo ещё раз. - let selectMesh = pick.mesh; - if (selectMesh?.metadata?._isBlockProto && this.blockManager) { - const proxy = this.blockManager.findProxyByPickInfo(pick.pickInfo); - if (proxy) selectMesh = proxy; - } - if (ctrlKey) { - this.selection.toggleMeshSelection(selectMesh); - } else { - this.selection.selectByMesh(selectMesh); - } - } - } else if (tool === 'block') { - const target = this._computePlacementCell(pick); - if (!target) return; - // Блоки живут в целочисленной сетке. Если кликнули по террейну, - // _computePlacementCell вернёт нецелый y (реальная высота - // поверхности) — округляем, чтобы блок встал ровно в клетку. - const by = Math.round(target.y); - const mesh = this.blockManager.addBlock(target.x, by, target.z, this._activeBlockType); - this._lastPlacedKey = `${target.x},${by},${target.z}`; - // Авто-выделение поставленного блока. Тени уже работают через proto-меш - // (зарегистрирован в refreshAllShadows и обновляется автоматически). - if (mesh) { - this.selection?.selectBlockAt(target.x, by, target.z); - if (this._onPostPlace) this._onPostPlace(); - } - } else if (tool === 'model') { - if (!this._activeModelType) return; - const cell = this._computePlacementCell(pick); - if (!cell) return; - // Пользовательская voxel-модель (id 'user:') — отдельный путь. - if (typeof this._activeModelType === 'string' - && this._activeModelType.startsWith(USER_MODEL_PREFIX)) { - this.userModelManager.addInstance( - this._activeModelType, cell.x, cell.y, cell.z, this._ghostRotationY, - { currentUserId: this._currentUserId || null }, - ).then(instId => { - if (instId != null) { - const data = this.userModelManager.instances.get(instId); - if (data?.meshes) { - for (const m of data.meshes) this.addShadowCaster(m); - } - // Регистрируем коллайдер - this._syncUserModelColliders(); - if (this._onPostPlace) this._onPostPlace(); - try { this._onSceneChange?.(); } catch (e) {} - // Инкремент uses_count — fire-and-forget - const numericId = parseUserModelId(this._activeModelType); - if (numericId != null && this._userModelsApi?.incrementModelUses) { - this._userModelsApi.incrementModelUses(numericId) - .catch(() => {}); - } - } - }); - return; - } - // addInstance модели — async, ждём id и выделяем - Promise.resolve(this.modelManager.addInstance( - this._activeModelType, cell.x, cell.y, cell.z, this._ghostRotationY - )).then(instId => { - if (instId != null) { - const data = this.modelManager.instances.get(instId); - if (data?.rootMesh && typeof data.rootMesh.getChildMeshes === 'function') { - // rootMesh — TransformNode, пропускаем его и берём только меши - for (const cm of data.rootMesh.getChildMeshes()) this.addShadowCaster(cm); - } - this.selection?.selectModelByInstanceId(instId); - if (this._onPostPlace) this._onPostPlace(); - } - }); - } else if (tool === 'primitive') { - if (!this._activePrimitiveType) return; - const cell = this._computePlacementCell(pick); - if (!cell) return; - // Примитив ставим так чтобы его НИЖНЯЯ грань была на клетке. - // Для куба/цилиндра/конуса pivot — центр, поэтому добавляем halfHeight. - const def = getPrimitiveType(this._activePrimitiveType); - const halfH = (def.defaultScale.y) / 2; - const newId = this.primitiveManager.addInstance(this._activePrimitiveType, { - x: cell.x, y: cell.y + halfH, z: cell.z, - }); - // Авто-выделение поставленного примитива - if (newId != null) { - const data = this.primitiveManager.instances.get(newId); - if (data?.mesh) this.addShadowCaster(data.mesh); - this.selection?.selectPrimitiveById(newId); - if (this._onPostPlace) this._onPostPlace(); - } - } else if (tool === 'erase') { - if (pick.mesh?.metadata?.isBlock) { - this.blockManager.removeBlockByMesh(pick.mesh); - } else if (pick.mesh?.metadata?.isModel) { - this.modelManager.removeInstanceByMesh(pick.mesh); - } else if (pick.mesh?.metadata?.isPrimitive) { - this.primitiveManager.removeInstanceByMesh(pick.mesh); - } - } - } - - /** - * Drag-постановка/удаление блоков. Вызывается на mousemove когда ЛКМ - * удерживается и активен tool=block/erase. - * - * Чтобы блоки не «лезли на игрока» при ведении мышью по сцене, фиксируем - * плоскость первого блока (X/Y/Z в зависимости от грани попадания): - * - Кликнул на пол / верх блока → drag по горизонтали (фиксируем Y) - * - Кликнул на боковую грань (по X) → drag по вертикальной плоскости (фиксируем X) - * - и т.д. - * - * isFirst=true — это первый клик drag'а, запоминаем ось фиксации. - */ - _dragPlaceTick(shiftKey, isFirst = false) { - if (this._isPlaying || !this.blockManager) return; - const tool = shiftKey ? 'erase' : this._activeTool; - if (tool !== 'block' && tool !== 'erase') return; - - const pick = this._pickFromMouse(); - if (!pick) return; - - if (tool === 'block') { - const target = this._computePlacementCell(pick); - if (!target) return; - - // Первый клик — запоминаем ось фиксации по нормали попадания - if (isFirst) { - const n = pick.normal; - if (Math.abs(n.y) > 0.5) { - this._dragLockAxis = 'y'; - this._dragLockValue = target.y; - } else if (Math.abs(n.x) > 0.5) { - this._dragLockAxis = 'x'; - this._dragLockValue = target.x; - } else if (Math.abs(n.z) > 0.5) { - this._dragLockAxis = 'z'; - this._dragLockValue = target.z; - } else { - this._dragLockAxis = null; - } - } else if (this._dragLockAxis) { - // На последующих движениях — переопределяем target в плоскости - if (target[this._dragLockAxis] !== this._dragLockValue) { - // Курсор ушёл с зафиксированной плоскости. Пересчитываем - // через raycast на полу/блоке, но с принудительной координатой. - target[this._dragLockAxis] = this._dragLockValue; - } - } - - const key = `${target.x},${target.y},${target.z}`; - if (key === this._lastPlacedKey) return; - if (this.blockManager.hasBlock(target.x, target.y, target.z)) return; - this.blockManager.addBlock(target.x, target.y, target.z, this._activeBlockType); - this._lastPlacedKey = key; - } else if (tool === 'erase') { - if (pick.mesh?.metadata?.isBlock) { - const m = pick.mesh.metadata; - const key = `${m.gridX},${m.gridY},${m.gridZ}`; - if (key === this._lastPlacedKey) return; - this.blockManager.removeBlockByMesh(pick.mesh); - this._lastPlacedKey = key; - } - } - } - - /** - * Обновить гизмо под текущее выделение. - */ - _updateGizmoForSelection(sel) { - if (!this._gizmo) return; - if (!sel) { - this._gizmo.attachTo(null); - return; - } - if (sel.type === 'block') { - this._gizmo.attachTo(sel.mesh); - } else if (sel.type === 'model' || sel.type === 'spawn' - || sel.type === 'userModel') { - this._gizmo.attachTo(sel.rootMesh); - } else if (sel.type === 'primitive') { - this._gizmo.attachTo(sel.mesh); - } - // Переустанавливаем режим чтобы sub-gizmo (move/rotate/scale) - // гарантированно пересоздалась поверх нового attached-mesh. - // Без этого гизмо иногда оказывается привязанным к старому или null - // объекту и стрелки становятся «неактивными». - this._gizmo.refreshMode(); - } - - /** - * Гизмо манипулировал объектом — синхронизируем через SelectionManager. - * Тип операции (move/rotate/scale) определяется по режиму гизмо. - */ - _onGizmoDragEnd() { - if (!this.selection || !this._gizmo) return; - const sel = this.selection.getSelection(); - if (!sel) return; - const mode = this._gizmo.getMode(); - - if (sel.type === 'block') { - if (mode === 'move') { - // Babylon-mesh.position — центр блока (gridX, gridY+0.5, gridZ) - const newX = Math.round(sel.mesh.position.x); - const newY = Math.round(sel.mesh.position.y - 0.5); - const newZ = Math.round(sel.mesh.position.z); - if (newX === sel.gridX && newY === sel.gridY && newZ === sel.gridZ) { - sel.mesh.position.set(sel.gridX, sel.gridY + 0.5, sel.gridZ); - return; - } - if (this.blockManager.hasBlock(newX, newY, newZ)) { - sel.mesh.position.set(sel.gridX, sel.gridY + 0.5, sel.gridZ); - return; - } - this.selection.moveSelectedBlock(newX, newY, newZ); - } - // Блоки не поворачиваем и не масштабируем (по дизайну voxel-сцены). - // Если пользователь дёрнул rotate/scale — игнорируем. - } else if (sel.type === 'model') { - const root = sel.rootMesh; - if (mode === 'move') { - this.selection.moveSelectedModel(root.position.x, root.position.y, root.position.z); - } else if (mode === 'rotate') { - this.selection.rotateSelectedModel(root.rotation.y); - } else if (mode === 'scale') { - // Берём средний масштаб (для равномерного скейла) - const avg = (root.scaling.x + root.scaling.y + root.scaling.z) / 3; - this.selection.scaleSelectedModel(avg); - } - } else if (sel.type === 'userModel') { - const root = sel.rootMesh; - if (mode === 'move') { - this.selection.moveSelectedUserModel(root.position.x, root.position.y, root.position.z); - } else if (mode === 'rotate') { - this.selection.rotateSelectedUserModel(root.rotation.y); - } else if (mode === 'scale') { - const avg = (root.scaling.x + root.scaling.y + root.scaling.z) / 3; - this.selection.scaleSelectedUserModel(avg); - } - } else if (sel.type === 'spawn') { - const root = sel.rootMesh; - if (mode === 'move') { - this.selection.moveSelectedSpawn(root.position.x, root.position.y, root.position.z); - } - } else if (sel.type === 'primitive') { - const root = sel.mesh; - if (mode === 'move') { - this.selection.moveSelectedPrimitive(root.position.x, root.position.y, root.position.z); - } else if (mode === 'rotate') { - // Сохраняем поворот в data → попадёт в serialize при save. - this.primitiveManager?.updateInstance(sel.id, { - rotationX: root.rotation.x, - rotationY: root.rotation.y, - rotationZ: root.rotation.z, - }); - } else if (mode === 'scale') { - // Снимаем scaling до пересоздания (после _recreateMesh старый mesh dispose'ится). - const newSx = sel.sx * root.scaling.x; - const newSy = sel.sy * root.scaling.y; - const newSz = sel.sz * root.scaling.z; - root.scaling.set(1, 1, 1); - this.selection.resizeSelectedPrimitive(newSx, newSy, newSz); - // resizeSelectedPrimitive уже обновил sel.mesh на новый и - // вызвал _highlightMesh. Перепривязываем гизмо к новому mesh. - const updatedSel = this.selection.getSelection(); - if (updatedSel?.mesh) { - this._gizmo.attachTo(updatedSel.mesh); - } - } - } - } - - /** Публичный сеттер: переключить инструмент извне (из React-компонента). */ - setActiveTool(toolName) { - this._activeTool = toolName; - if (this._ghostMesh) { - this._ghostMesh.setEnabled(toolName === 'block'); - } - // Preview-кисть террейна. Если меш ещё не создан — создадим лениво - // при первом setTerrainBrush (это произойдёт в TerrainPanel useEffect). - if (toolName === 'terrain') { - if (!this._terrainBrushPreview && this._terrainBrush) { - this._updateTerrainBrushPreview(); - } - if (this._terrainBrushPreview) { - this._terrainBrushPreview.setEnabled(true); - } - } else if (this._terrainBrushPreview) { - this._terrainBrushPreview.setEnabled(false); - } - } - - /** - * Обновить состояние кисти ландшафта из TerrainPanel. - * Принимает частичный объект — то что не задано, не меняется. - */ - setTerrainBrush(patch) { - if (!this._terrainBrush) return; - const prevTool = this._terrainBrush.tool; - Object.assign(this._terrainBrush, patch || {}); - this._updateTerrainBrushPreview(); - // Инструмент «Выбрать деко»: включаем пикинг thin-instance декораций, - // при уходе с инструмента — выключаем и снимаем подсветку. - const nowPick = this._terrainBrush.tool === 'pickDeco'; - const wasPick = prevTool === 'pickDeco'; - if (nowPick !== wasPick) { - if (this._smoothDecoManager?.setPickingEnabled) { - this._smoothDecoManager.setPickingEnabled(nowPick); - } - if (!nowPick) this._clearDecoSelection(); - } - } - - /** Снять подсветку выбранной декорации (маркер-сфера). */ - _clearDecoSelection() { - if (this._decoSelMarker) { - try { this._decoSelMarker.dispose(); } catch (e) {} - this._decoSelMarker = null; - } - this._decoSelection = null; - } - - /** - * Клик инструментом «Выбрать деко»: raycast по thin-instance декорациям, - * подсветка выбранного дерева/куста маркером. Удаление — по Del - * (обрабатывается в _deleteSelectedDeco). - */ - _pickDecoTick() { - const dm = this._smoothDecoManager; - if (!dm) return; - const pick = this.scene.pick(this.scene.pointerX, this.scene.pointerY, - (m) => m.isPickable && m.name && m.name.startsWith('__smoothDeco_')); - if (!pick || !pick.hit || !pick.pickedMesh) { - this._clearDecoSelection(); - return; - } - const thinIdx = pick.thinInstanceIndex; - const found = dm.findInstanceByPick(pick.pickedMesh, thinIdx); - if (!found) { - this._clearDecoSelection(); - return; - } - this._decoSelection = found; - // Маркер-подсветка: жёлтая полупрозрачная сфера над выбранным деко. - if (this._decoSelMarker) { - try { this._decoSelMarker.dispose(); } catch (e) {} - } - const marker = MeshBuilder.CreateSphere('__decoSelMarker', { diameter: 3, segments: 10 }, this.scene); - const mat = new StandardMaterial('__decoSelMarkerMat', this.scene); - mat.emissiveColor = new Color3(1, 0.85, 0.1); - mat.alpha = 0.35; - mat.disableLighting = true; - marker.material = mat; - marker.isPickable = false; - marker.position.set(found.x, found.y + 2, found.z); - this._decoSelMarker = marker; - console.log(`[pickDeco] выбрана ${found.decoKey} @ (${found.x.toFixed(1)},${found.z.toFixed(1)})`); - } - - /** Удалить выбранную инструментом «Выбрать деко» декорацию (вызов по Del). */ - _deleteSelectedDeco() { - if (!this._decoSelection || !this._smoothDecoManager) return false; - const { decoKey, fullIndex } = this._decoSelection; - const ok = this._smoothDecoManager.removeInstanceAt(decoKey, fullIndex); - if (ok) { - // Пересинхронизировать tree-collider'ы (вдруг удалили дерево) - if (this.physics?.setSmoothDecoTrees) { - this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders()); - } - this._clearDecoSelection(); - try { this._onSceneChange?.(); } catch (e) {} - } - return ok; - } - - /** - * Обновить geometry preview-меша кисти (полупрозрачная сфера/куб - * по форме кисти, размер 2*radius). Цвет берётся из текущего материала. - */ - _updateTerrainBrushPreview() { - if (!this._terrainBrush) return; - const { brushSize, shape, material } = this._terrainBrush; - const r = Math.max(1, brushSize); - // Берём цвет напрямую из палитры — стабильный hex, не зависит от того - // загружена ли уже текстура. Превью кисти просто тонируется в основной - // цвет выбранного материала. - // eslint-disable-next-line global-require - const { Color3 } = require('@babylonjs/core'); - let matCol = null; - try { - const def = TERRAIN_MATERIAL_DEFS?.[material]; - if (def?.color) matCol = Color3.FromHexString(def.color); - } catch (e) {} - - // Удаляем старый preview если форма/размер/режим изменились - if (this._terrainBrushPreview) { - const md = this._terrainBrushPreview.metadata || {}; - const curMode = this._terrainBrush?.terrainMode || 'voxel'; - if (md.shape !== shape || md.radius !== r || md.terrainMode !== curMode) { - try { this._terrainBrushPreview.dispose(); } catch (e) {} - this._terrainBrushPreview = null; - } - } - - if (!this._terrainBrushPreview) { - // eslint-disable-next-line global-require - const { MeshBuilder, StandardMaterial, Color3 } = require('@babylonjs/core'); - // Размер кисти в МИРОВЫХ единицах: - // voxel-режим: r (voxel) × VOXEL_SIZE × 2 + 1 voxel (центр) - // smooth-режим: r — это РАДИУС В МЕТРАХ, диаметр = r*2 - const isSmooth = this._terrainBrush?.terrainMode === 'smooth'; - const worldDiameter = isSmooth - ? r * 2 - : (r * 2 + 1) * TERRAIN_VOXEL_SIZE; - let mesh; - if (shape === 'cube') { - mesh = MeshBuilder.CreateBox('__terrainBrushPreview', { size: worldDiameter }, this.scene); - } else if (shape === 'cylinder') { - mesh = MeshBuilder.CreateCylinder('__terrainBrushPreview', { height: worldDiameter, diameter: worldDiameter }, this.scene); - } else { - mesh = MeshBuilder.CreateSphere('__terrainBrushPreview', { diameter: worldDiameter, segments: 16 }, this.scene); - } - const mat = new StandardMaterial('__terrainBrushPreviewMat', this.scene); - mat.emissiveColor = matCol || new Color3(0.2, 0.7, 0.3); - mat.diffuseColor = new Color3(0, 0, 0); - mat.alpha = 0.22; - mat.disableLighting = false; - mat.backFaceCulling = false; - mesh.material = mat; - mesh.isPickable = false; - mesh.metadata = { - _isTerrainBrushPreview: true, - shape, radius: r, - terrainMode: this._terrainBrush?.terrainMode || 'voxel', - _baseAlpha: 0.22, - _activeAlpha: 0.45, - }; - mesh.setEnabled(this._activeTool === 'terrain'); - this._terrainBrushPreview = mesh; - } else { - // Обновляем цвет - if (matCol) { - try { this._terrainBrushPreview.material.emissiveColor = matCol; } catch (e) {} - } - } - } - - /** - * Вычислить точку клика для кисти террейна. Возвращает {x, y, z} в - * voxel-координатах (целые) или null. - * - * Логика: - * 1. Если попали по существующему voxel'у — берём клетку или соседнюю - * по нормали (в зависимости от инструмента). - * 2. Иначе делаем raycast на плоскость y=0 (пол сцены) — это даёт - * площадку «где начнётся ландшафт». - */ - _pickTerrainCell(forNewVoxel) { - const pi = this.scene.pick( - this.scene.pointerX, - this.scene.pointerY, - (mesh) => { - if (!mesh.isPickable) return false; - if (mesh === this._ghostMesh) return false; - if (mesh === this._terrainBrushPreview) return false; - if (mesh.metadata?._isBlockProto) return false; - if (mesh.metadata?._isTerrainProto) return false; - if (mesh.name && mesh.name.startsWith('gridLine')) return false; - return true; - } - ); - - // Сначала — попытка raycast по существующему террейну (DDA) - const camera = this.scene.activeCamera; - const ray = this.scene.createPickingRay( - this.scene.pointerX, - this.scene.pointerY, - null, - camera, - false, - ); - const tHit = this.terrainManager?.pickVoxelByRay(ray.origin, ray.direction, 200); - if (tHit) { - if (forNewVoxel) { - return { - x: tHit.cell.x + tHit.normal.x, - y: tHit.cell.y + tHit.normal.y, - z: tHit.cell.z + tHit.normal.z, - }; - } - return tHit.cell; - } - - // Иначе — попадание по обычным мешам (пол/блок). Делим мировые - // координаты на TERRAIN_VOXEL_SIZE чтобы получить voxel-индекс. - if (pi?.hit && pi.pickedPoint) { - const p = pi.pickedPoint; - const n = pi.getNormal?.(true) || { x: 0, y: 1, z: 0 }; - // Идём на 0.001 внутрь по противоположной нормали — чтобы координата - // упала в правильный voxel. - const inside = { - x: p.x - n.x * 0.001, - y: p.y - n.y * 0.001, - z: p.z - n.z * 0.001, - }; - const S = TERRAIN_VOXEL_SIZE; - const baseX = Math.floor(inside.x / S); - const baseY = Math.floor(inside.y / S); - const baseZ = Math.floor(inside.z / S); - if (forNewVoxel) { - return { - x: baseX + Math.round(n.x), - y: baseY + Math.round(n.y), - z: baseZ + Math.round(n.z), - }; - } - return { x: baseX, y: baseY, z: baseZ }; - } - - // Fallback — raycast на плоскость y=0. Преобразуем мировые - // координаты в voxel-индексы делением на TERRAIN_VOXEL_SIZE. - if (Math.abs(ray.direction.y) > 1e-4) { - const t = -ray.origin.y / ray.direction.y; - if (t > 0 && t < 200) { - const hx = ray.origin.x + ray.direction.x * t; - const hz = ray.origin.z + ray.direction.z * t; - const S = TERRAIN_VOXEL_SIZE; - const cx = Math.floor(hx / S); - const cz = Math.floor(hz / S); - // Прилипание к верху столбца: если в этом столбце уже - // есть voxel'ы террейна, используем Y верхнего + 1. - // Это убирает «прыжок» кисти когда юзер начинает рисовать - // на пол рядом с уже существующим холмом — она остаётся - // на уровне его поверхности. - let topY = -1; - if (this.terrainManager) { - const found = this.terrainManager._findTopY?.(cx, cz, 200, -50); - if (found !== null && found !== undefined) topY = found; - } - return { - x: cx, - y: forNewVoxel && topY >= 0 ? topY + 1 : (topY >= 0 ? topY : 0), - z: cz, - }; - } - } - return null; - } - - /** - * Один «тик» кисти террейна. Вызывается при mousedown и mousemove с зажатой - * ЛКМ когда activeTool === 'terrain'. - * - * shiftKey — модификатор «обратное действие» (стереть/опустить). - */ - _terrainBrushTick(shiftKey, isFirst) { - if (this._isPlaying) return; - if (this._activeTool !== 'terrain') return; - - // === Smooth-режим: редактируем DensityGrid через SmoothBrushes === - if (this._terrainBrush?.terrainMode === 'smooth') { - this._smoothBrushTick(shiftKey, isFirst); - return; - } - - if (!this.terrainManager) return; - const tool = this._terrainBrush?.tool || 'draw'; - - // === Rate-limit voxel-кисти === - // mousemove приходит ~100Hz, каждый тик brushDraw/sculpt с radius=16 - // = ~17000 thinInstanceAdd. Даже с GPU-batch это съедает кадр. - // Ограничиваем тики до ~25 Hz (40ms) — кисть всё равно плавно - // покрывает поверхность за счёт drag по экрану. - if (!isFirst) { - const now = performance.now(); - const last = this._voxelBrushLastTick || 0; - const radius = this._terrainBrush?.brushSize || 4; - // Чем больше кисть, тем реже тики (защита от лагов). - const minInterval = radius <= 4 ? 30 : radius <= 8 ? 50 : radius <= 16 ? 80 : 120; - if (now - last < minInterval) return; - this._voxelBrushLastTick = now; - } else { - this._voxelBrushLastTick = performance.now(); - } - - // === Plant-кисти voxel-режима: размещение мини-воксельных моделей === - // plantGrass / plantFlower / plantMushroom / plantTree. - // Shift = стереть декорации в зоне. - if (tool === 'plantGrass' || tool === 'plantFlower' - || tool === 'plantMushroom' || tool === 'plantTree') { - // При новом клике сбрасываем rate-limit pos, чтобы первый клик - // в той же точке всегда срабатывал. - if (isFirst) { - this._voxelTreeLastPos = null; - this._voxelPlantLastPos = null; - } - const cell = this._pickTerrainCell(true); - if (!cell) return; - const brush = { - x: cell.x, y: cell.y, z: cell.z, - radius: this._terrainBrush.brushSize || 4, - shape: this._terrainBrush.shape || 'sphere', - strength: this._terrainBrush.strength ?? 50, - }; - if (shiftKey) { - this._eraseDecorationsInBrush(brush); - } else { - this._placeVoxelPlantsAtBrush(brush, tool); - } - return; - } - - // Для перекраски/выровнять — берём клетку с поверхности (не «над») - // Для рисования — клетку «над» (по нормали) - const wantsAdjacent = (tool === 'draw' || tool === 'sculpt'); - const cell = this._pickTerrainCell(wantsAdjacent); - if (!cell) return; - - // На скульпте и выровнять — фиксируем Y первой точки, чтобы при drag - // не перепрыгивать на разные слои каждым движением мыши. - if (isFirst) { - this._terrainDragLockY = cell.y; - } - - const brush = { - x: cell.x, - y: (tool === 'flatten' || tool === 'sculpt' || tool === 'smooth') - ? this._terrainDragLockY ?? cell.y - : cell.y, - z: cell.z, - radius: this._terrainBrush.brushSize || 4, - shape: this._terrainBrush.shape || 'sphere', - }; - const matId = this._terrainBrush.material || 'grass'; - const strength = this._terrainBrush.strength ?? 50; - - if (shiftKey) { - // Shift = обратная кисть. Для sculpt — опускает (Sculpt Down). - // Для всех остальных — стирает (voxels + декорации в зоне). - if (tool === 'sculpt') { - this.terrainManager.brushSculpt(brush, -1, matId, strength); - } else { - this.terrainManager.brushErase(brush); - this._eraseDecorationsInBrush(brush); - } - return; - } - - // === Деко-материалы → мини-воксельные модели === - // Если выбран материал-декорация (трава/цветы/грибы/листья) — кисть - // ставит МОДЕЛЬ из DecoModels, а не плоский voxel. Это для tool=draw, - // sculpt (рисование) — где пользователь "красит" декорациями. - // smooth/paint/flatten — стандартный voxel-rendering. - if (this._isDecoMaterial(matId) && (tool === 'draw' || tool === 'sculpt')) { - this._placeDecoModelsAtBrush(brush, matId); - return; - } - - // Если выбран деко-материал но инструмент НЕ ставит модели (smooth/paint/flatten), - // нельзя засыпать столбцы декорациями — fallback на 'grass'. - const safeMatId = this._isDecoMaterial(matId) ? 'grass' : matId; - - switch (tool) { - case 'draw': - this.terrainManager.brushDraw(brush, matId); - break; - case 'sculpt': - this.terrainManager.brushSculpt(brush, +1, matId, strength); - break; - case 'smooth': - // Smooth работает БЕЗ выбранного материала: засыпка идёт - // тем материалом, что уже есть у соседних solid voxels. - this.terrainManager.brushSmooth(brush, null); - break; - case 'paint': - this.terrainManager.brushPaint(brush, safeMatId); - break; - case 'flatten': - this.terrainManager.brushFlatten(brush, safeMatId); - break; - case 'erase': - // Стираем И voxels И декорации в зоне кисти. - this.terrainManager.brushErase(brush); - this._eraseDecorationsInBrush(brush); - break; - default: - break; - } - } - - /** Переключение «активного» состояния preview-меша кисти террейна. - * active=true делает кисть ярче (alpha 0.45 vs 0.22) — пока зажата ЛКМ. */ - _setTerrainBrushPreviewActive(active) { - const m = this._terrainBrushPreview; - if (!m || !m.material) return; - try { - const meta = m.metadata || {}; - m.material.alpha = active ? (meta._activeAlpha || 0.45) : (meta._baseAlpha || 0.22); - } catch (e) {} - } - - // ======================================================================== - // Undo / Redo для террейна - // - // Хранится стек снапшотов всего террейна (массив serialize'данных). - // Один drag-мазок кистью = один снапшот. История ограничена 30 шагами - // (чтобы не съесть RAM при больших террейнах). - // - // Использование: - // _terrainHistoryOpen() — перед началом мазка (mousedown по террейну) - // _terrainHistoryClose() — после конца мазка (mouseup): если что-то - // изменилось, фиксируем «открытый» снапшот - // undoTerrain() / redoTerrain() — горячие клавиши Ctrl+Z / Ctrl+Y - // ======================================================================== - - /** - * Маппинг "деко-материалов" (выбираемых в палитре voxel-режима) на - * `modelId` из DECO_MODELS. Если material есть в этой карте — кисть - * ставит МОДЕЛЬ через DecoManager, а не плоский voxel. - */ - _decoMaterialToModels(matId) { - switch (matId) { - case 'tall_grass': - // Случайная модель травы из pool — каждый клик ставит разные - return GRASS_MODELS_POOL; - case 'flower_red': return ['poppy']; - case 'flower_blue': return ['cornflower']; - case 'flower_yellow': return ['dandelion', 'daisy']; - case 'mushroom_red': return ['fly_mushroom']; - // Эти материалы можно тоже спрятать под деко если хочется, - // но пока оставляем как voxels (они нужны для деревьев и т.п.) - // case 'leaves': case 'leaves_orange': case 'rock_moss': case 'trunk': - default: return null; - } - } - - /** True если material — декорация (ставится моделью). */ - _isDecoMaterial(matId) { - return this._decoMaterialToModels(matId) !== null; - } - - /** - * Поставить мини-воксельные модели в зоне кисти (sphere/cube). - * Плотность — 30% точек grid в радиусе → штук 5-15 на клик. - */ - _placeDecoModelsAtBrush(brush, matId) { - if (!this.decoManager) return; - const models = this._decoMaterialToModels(matId); - if (!models || models.length === 0) return; - const TERRAIN_VOXEL = 0.25; - const r = brush.radius; - // brush.x/y/z в voxel-индексах террейна (cells 0.25м). - // Мировые координаты центра brush в МЕТРАХ. - const cx = (brush.x + 0.5) * TERRAIN_VOXEL; - const cz = (brush.z + 0.5) * TERRAIN_VOXEL; - // Top-surface Y: ищем верх solid voxels под brush.x,brush.z. - // Если на этой колонне нет voxel — ставим прямо на baseplate y=0. - let topVoxY = brush.y; - // count = ~ 8 случайных позиций - const COUNT = 10; - const placedKeys = new Set(); - for (let i = 0; i < COUNT; i++) { - // Случайная точка в круге radius (в voxel-units) - const angle = Math.random() * Math.PI * 2; - const rr = Math.sqrt(Math.random()) * r; - const vx = brush.x + Math.cos(angle) * rr; - const vz = brush.z + Math.sin(angle) * rr; - const worldX = (vx + 0.5) * TERRAIN_VOXEL; - const worldZ = (vz + 0.5) * TERRAIN_VOXEL; - // Y: top surface — используем brush.y + 1 voxel (над поверхностью) - const worldY = (topVoxY + 1) * TERRAIN_VOXEL; - // Защита от дублирования: не ставим 2 модели в одну сетку 0.5м - const key = `${Math.floor(worldX*2)},${Math.floor(worldZ*2)}`; - if (placedKeys.has(key)) continue; - placedKeys.add(key); - // Случайная модель из набора - const modelId = models[Math.floor(Math.random() * models.length)]; - const rotation = Math.random() * Math.PI * 2; - const scale = 0.9 + Math.random() * 0.3; - this.decoManager.placeModel(worldX, worldY, worldZ, modelId, rotation, scale); - } - try { this._onSceneChange?.(); } catch (e) {} - } - - /** - * Поставить декорации plant-кистью voxel-режима по типу инструмента. - * tool: plantGrass | plantFlower | plantMushroom | plantTree. - * Trees — из voxel-блоков (trunk + leaves), остальные — мини-модели. - */ - _placeVoxelPlantsAtBrush(brush, tool) { - if (tool === 'plantTree') { - this._placeVoxelTreesAtBrush(brush); - return; - } - // Подбираем пул моделей по типу инструмента - let models; - let countMul; // множитель плотности на тик (как в smooth: grass=густо, flower=средне) - switch (tool) { - case 'plantGrass': - models = GRASS_MODELS_POOL; - countMul = 1.5; - break; - case 'plantFlower': - models = ['daisy', 'cornflower', 'poppy', 'dandelion']; - countMul = 1.0; - break; - case 'plantMushroom': - models = ['fly_mushroom', 'brown_mushroom']; - countMul = 0.5; - break; - default: - return; - } - if (!this.decoManager || !models || models.length === 0) return; - // Rate-limit между тиками: не ставим если кисть не сдвинулась. - const r = brush.radius; - const minDist = Math.max(1, r * 0.3); - const minDist2 = minDist * minDist; - if (this._voxelPlantLastPos) { - const dx = brush.x - this._voxelPlantLastPos.x; - const dz = brush.z - this._voxelPlantLastPos.z; - if (dx * dx + dz * dz < minDist2) return; - } - this._voxelPlantLastPos = { x: brush.x, z: brush.z }; - const TERRAIN_VOXEL = 0.25; - const topVoxY = brush.y; - // Кол-во точек пропорционально радиусу и типу декорации. - const COUNT = Math.max(2, Math.min(16, Math.round(r * countMul))); - const placedKeys = new Set(); - for (let i = 0; i < COUNT; i++) { - const angle = Math.random() * Math.PI * 2; - const rr = Math.sqrt(Math.random()) * r; - const vx = brush.x + Math.cos(angle) * rr; - const vz = brush.z + Math.sin(angle) * rr; - const worldX = (vx + 0.5) * TERRAIN_VOXEL; - const worldZ = (vz + 0.5) * TERRAIN_VOXEL; - const worldY = (topVoxY + 1) * TERRAIN_VOXEL; - const key = `${Math.floor(worldX*2)},${Math.floor(worldZ*2)}`; - if (placedKeys.has(key)) continue; - placedKeys.add(key); - const modelId = models[Math.floor(Math.random() * models.length)]; - const rotation = Math.random() * Math.PI * 2; - const scale = 0.9 + Math.random() * 0.3; - this.decoManager.placeModel(worldX, worldY, worldZ, modelId, rotation, scale); - } - try { this._onSceneChange?.(); } catch (e) {} - } - - /** - * Поставить ОДНО красивое процедурное дерево из voxel-блоков под кистью. - * - * Логика как в smooth-режиме (`_smoothBrushTickPlant`): - * - 1 дерево за тик (а не пучок) - * - rate-limit: если кисть не сдвинулась далеко, пропускаем тик - * - случайный выбор типа: oak / birch / autumn - * - * Использует `placeVoxelTree` из VoxelTreeBuilder.js (тот же алгоритм, - * который генерирует деревья в процедурном мире — толстый ствол, - * корни, ветви-зигзаги, главная крона + кроны на ветвях). - */ - _placeVoxelTreesAtBrush(brush) { - if (!this.terrainManager) return; - const tm = this.terrainManager; - - // === Rate-limit между тиками === - // Один тик = одно дерево. Если кисть не сдвинулась более чем на - // 0.4×radius (в voxel-units), пропускаем. Это убирает спам деревьев - // друг на друге при удержании ЛКМ. - const r = brush.radius; - const minDist = Math.max(2, r * 0.4); - const minDist2 = minDist * minDist; - if (this._voxelTreeLastPos) { - const dx = brush.x - this._voxelTreeLastPos.x; - const dz = brush.z - this._voxelTreeLastPos.z; - if (dx * dx + dz * dz < minDist2) return; - } - this._voxelTreeLastPos = { x: brush.x, z: brush.z }; - - // === Случайная точка в круге кисти (jitter) === - const angle = Math.random() * Math.PI * 2; - const rr = Math.sqrt(Math.random()) * r * 0.5; // не до края — деревья ближе к центру - const tx = Math.round(brush.x + Math.cos(angle) * rr); - const tz = Math.round(brush.z + Math.sin(angle) * rr); - - // === Top-surface для этой XZ === - const topY = tm._findTopY?.(tx, tz, brush.y + r * 4, brush.y - r * 4); - const baseY = (topY === null || topY === undefined) ? brush.y : topY; - - // === Размер дерева от strength (1..100) === - // strength=10 → саженец (sizeScale=0.5) - // strength=50 → стандарт (sizeScale=1.0) - // strength=100 → большое (sizeScale=2.0) - const strength = brush.strength ?? 50; - const sizeScale = 0.5 + (strength / 100) * 1.5; - - // === Случайный тип дерева === - const type = TREE_TYPES[Math.floor(Math.random() * TREE_TYPES.length)]; - - // === Уникальный seed на каждое дерево — даёт разную форму === - const seed = (Math.random() * 0x7fffffff) | 0; - - // === Ставим voxels через batched setVoxel-fn === - // tm._addInstance не обновляет GPU buffer в batch-режиме, делаем - // один flushBatch в конце. Это превращает ~300 add'ов в один upload. - tm._beginBatch?.(); - let placed = 0; - try { - const setVoxelFn = (x, y, z, matId) => { - const k = `${x},${y},${z}`; - if (tm.voxels.has(k)) return; - tm._addInstance?.(k, x, y, z, matId); - tm.voxels.set(k, matId); - placed++; - }; - placeVoxelTree(setVoxelFn, tx, baseY, tz, type, sizeScale, seed); - } finally { - tm._flushBatch?.(); - } - if (placed > 0) { - try { tm._emit?.(); } catch (e) {} - try { this._onSceneChange?.(); } catch (e) {} - } - } - - /** Удалить все декорации в зоне кисти (по миру). */ - _eraseDecorationsInBrush(brush) { - if (!this.decoManager || !this.decoManager.placements) return; - const TERRAIN_VOXEL = 0.25; - const cx = (brush.x + 0.5) * TERRAIN_VOXEL; - const cz = (brush.z + 0.5) * TERRAIN_VOXEL; - const r = brush.radius * TERRAIN_VOXEL * 1.2; // чуть больше для удобства - const r2 = r * r; - const keep = []; - let removed = 0; - for (const p of this.decoManager.placements) { - const dx = p.x - cx; - const dz = p.z - cz; - if (dx * dx + dz * dz <= r2) { - removed++; - } else { - keep.push(p); - } - } - if (removed > 0) { - // Перезагружаем decoManager с обновлённым списком - this.decoManager.clear(); - this.decoManager.loadFromArray(keep); - try { this._onSceneChange?.(); } catch (e) {} - } - } - - _terrainHistoryEnsure() { - if (!this._terrainHistory) { - this._terrainHistory = { stack: [], cursor: -1, pending: null }; - } - return this._terrainHistory; - } - _terrainHistoryOpen() { - const tm = this.terrainManager; - if (!tm) return; - const h = this._terrainHistoryEnsure(); - // Снапшот до изменения - h.pending = tm.serialize(); - } - _terrainHistoryClose() { - const tm = this.terrainManager; - if (!tm) return; - const h = this._terrainHistoryEnsure(); - if (!h.pending) return; - const after = tm.serialize(); - // Сравниваем размером — если ничего не изменилось, не пушим - if (after.length === h.pending.length && this._terrainSerEqual(after, h.pending)) { - h.pending = null; - return; - } - // Обрубаем все «впереди-курсора» (redo-стек после нового действия) - if (h.cursor < h.stack.length - 1) { - h.stack.length = h.cursor + 1; - } - h.stack.push(h.pending); - h.cursor = h.stack.length - 1; - // Ограничение 30 шагов - const MAX = 30; - while (h.stack.length > MAX) { - h.stack.shift(); - h.cursor--; - } - h.pending = null; - } - _terrainSerEqual(a, b) { - // Сравнение двух serialize-массивов. O(n) с использованием Set. - if (a.length !== b.length) return false; - const sa = new Set(); - for (const v of a) sa.add(`${v.x},${v.y},${v.z},${v.m}`); - for (const v of b) if (!sa.has(`${v.x},${v.y},${v.z},${v.m}`)) return false; - return true; - } - undoTerrain() { - const tm = this.terrainManager; - if (!tm) return false; - const h = this._terrainHistoryEnsure(); - if (h.cursor < 0) return false; - // Текущий стейт в редо-позицию (cursor+1) - const current = tm.serialize(); - const target = h.stack[h.cursor]; - // Если на cursor лежит снапшот «до», нам нужно вернуться к нему. - // Cursor указывает на последнее ВОЗВРАТНОЕ состояние. - tm.loadFromArray(target); - // Записываем текущий стейт в позицию cursor+1 для возможного redo - h.stack[h.cursor + 1] = current; - h.cursor--; - return true; - } - redoTerrain() { - const tm = this.terrainManager; - if (!tm) return false; - const h = this._terrainHistoryEnsure(); - if (h.cursor + 1 >= h.stack.length - 0) return false; - const target = h.stack[h.cursor + 2]; - if (!target) return false; - tm.loadFromArray(target); - h.cursor++; - return true; - } - - // ======================================================================== - // Регион террейна (инструменты «Выделить», «Заполнить», «Преобразовать») - // - // Регион — это объёмная коробка, заданная двумя углами в voxel-индексах. - // Визуализируется wireframe-боксом. Создаётся drag-rectangle'ом на земле: - // первый mousedown в режиме «Выделить» — стартовый угол, drag — второй. - // Высота коробки фиксируется ±radius по Y вокруг плоскости клика. - // - // Регион используется: - // • «Заполнить» — залить регион выбранным материалом - // • «Преобразовать» — переместить все voxel'ы региона в новое место - // (плоский drag по XZ, без поворота на этапе 2). - // ======================================================================== - - /** Текущее выделение или null. Структура: {x0,y0,z0,x1,y1,z1} (включительно). */ - getTerrainRegion() { return this._terrainRegion || null; } - - /** Очистить выделение и убрать визуализацию. */ - clearTerrainRegion() { - this._terrainRegion = null; - if (this._terrainRegionMesh) { - try { this._terrainRegionMesh.dispose(); } catch (e) {} - this._terrainRegionMesh = null; - } - } - - /** Обновить wireframe-визуализацию региона по this._terrainRegion. */ - _updateTerrainRegionVisual() { - const r = this._terrainRegion; - if (this._terrainRegionMesh) { - try { this._terrainRegionMesh.dispose(); } catch (e) {} - this._terrainRegionMesh = null; - } - if (!r) return; - - // eslint-disable-next-line global-require - const { MeshBuilder, StandardMaterial, Color3 } = require('@babylonjs/core'); - const S = TERRAIN_VOXEL_SIZE; - const minX = Math.min(r.x0, r.x1); - const maxX = Math.max(r.x0, r.x1); - const minY = Math.min(r.y0, r.y1); - const maxY = Math.max(r.y0, r.y1); - const minZ = Math.min(r.z0, r.z1); - const maxZ = Math.max(r.z0, r.z1); - // Размер в мире — кол-во клеток × VOXEL_SIZE. +1 потому что включительно. - const sizeX = (maxX - minX + 1) * S; - const sizeY = (maxY - minY + 1) * S; - const sizeZ = (maxZ - minZ + 1) * S; - const cx = (minX + 0.5) * S + (sizeX - S) / 2; - const cy = (minY + 0.5) * S + (sizeY - S) / 2; - const cz = (minZ + 0.5) * S + (sizeZ - S) / 2; - - const mesh = MeshBuilder.CreateBox('__terrainRegion', { - width: sizeX, height: sizeY, depth: sizeZ, - }, this.scene); - mesh.position.set(cx, cy, cz); - mesh.isPickable = false; - const mat = new StandardMaterial('__terrainRegionMat', this.scene); - mat.wireframe = true; - mat.emissiveColor = new Color3(0.20, 0.55, 1.00); - mat.diffuseColor = new Color3(0, 0, 0); - mat.alpha = 0.9; - mesh.material = mat; - mesh.metadata = { _isTerrainRegion: true }; - this._terrainRegionMesh = mesh; - } - - /** Запустить выделение региона: pickStart — voxel-клетка начала. */ - _terrainBeginRegion(pickStart) { - const radius = this._terrainBrush?.brushSize || 4; - // Высота региона по умолчанию = ±radius от Y клика. При drag юзер - // может уточнить — но через первый MVP оставим фиксированной. - this._terrainRegion = { - x0: pickStart.x, y0: Math.max(0, pickStart.y - radius), z0: pickStart.z, - x1: pickStart.x, y1: pickStart.y + radius, z1: pickStart.z, - }; - this._terrainRegionDragging = true; - this._updateTerrainRegionVisual(); - } - - /** Обновить второй угол региона по новой voxel-клетке. */ - _terrainUpdateRegion(pickEnd) { - if (!this._terrainRegion) return; - this._terrainRegion.x1 = pickEnd.x; - this._terrainRegion.z1 = pickEnd.z; - // Y оставляем как поставили при начале — drag по плоскости XZ - this._updateTerrainRegionVisual(); - } - - /** Завершить drag выделения. */ - _terrainEndRegion() { - this._terrainRegionDragging = false; - } - - /** - * Инициализировать пустой smooth-terrain для скульптинга с нуля. - * Создаёт DensityGrid 100×24×100 cells (400×96×400м) с density=0 везде. - * Первый клик sculpt-кистью сразу породит холм в нужном месте. - */ - _initEmptySmoothTerrain() { - if (this._robloxTerrain) { - try { this._robloxTerrain.disposeAll(); } catch (e) {} - } - this._robloxTerrain = new RobloxTerrain(this.scene); - if (this.physics?.setRobloxTerrain) { - this.physics.setRobloxTerrain(this._robloxTerrain); - } - const sx = 100, sy = 24, sz = 100; - const grid = new RobloxDensityGrid({ - origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 }, - size: { x: sx, y: sy, z: sz }, - }); - // Регистрируем стандартные материалы в палитре (нужно для brushes). - // Index 0 = пусто, далее по порядку для совместимости с web/Android. - for (const matKey of ['grass', 'rock', 'sand', 'snow', 'dirt']) { - // Hack: set одной ячейки потом обнуляем, чтобы добавить в palette. - grid.set(0, 0, 0, 0, matKey); - } - // Сбрасываем (0,0,0) обратно в пусто — но matData[0] остался matId - // последнего set'а. Обнуляем явно. - grid.densityData[0] = 0; - grid.matData[0] = 0; - // skipEmpty: true — НЕ добавляем 98 пустых chunks в pending, - // mesher будет работать только после первой кисти. - this._robloxTerrain.loadFromGrid(grid, { skipEmpty: true }); - // НЕ отключаем baseplate сразу — нужен чтобы raycast мог пикать - // плоскость y=0 при первых кликах. Отключим когда появится хоть один - // solid chunk (см. updateStreaming / applyBrushAndRebuild). - console.log('[BabylonScene] _initEmptySmoothTerrain: 100×24×100 grid created (skipEmpty=true)'); - } - - /** - * Тик smooth-кисти. Делает raycast по mesh-ам smooth-terrain, - * получает worldPosition и вызывает applyBrush в этой точке. - * - * Если smooth-terrain ещё не создан (свежий проект, нажимаем sculpt - * на пустой сцене) — создаём пустой DensityGrid 100×24×100 cells - * (400×96×400м) при первом клике sculpt/fill, как в Roblox Studio. - */ - _smoothBrushTick(shiftKey, isFirst) { - const tool = this._terrainBrush?.tool || 'sculpt'; - // === Инструмент «Выбрать деко» — поштучный клик-выбор декораций === - // Не модифицирует ландшафт, работает только на клик (не drag). - if (tool === 'pickDeco') { - if (isFirst) this._pickDecoTick(); - return; - } - const terrainEmpty = !this._robloxTerrain || !this._robloxTerrain.grid; - - // === Счётчики для диагностики === - if (!this._smoothBrushDiag) { - this._smoothBrushDiag = { - tickCount: 0, hitTerrain: 0, hitGround: 0, hitNone: 0, - applyResult: { built: 0, dirty0: 0 }, - }; - } - const D = this._smoothBrushDiag; - D.tickCount++; - const tickN = D.tickCount; - - // === Plane-lock + rate-limit (как в Roblox Studio) === - // При isFirst — фиксируем плоскость и позицию первого клика. - // Дальше при drag: - // 1) center.y берётся не из raycast (он растёт вверх вслед за рельефом), - // а из зафиксированного _smoothBrushLockY → кисть работает в плоскости. - // 2) Между tick'ами требуется минимальное расстояние (0.6×radius) — - // иначе одна и та же точка → cells доходят до 255 → "какаха". - if (isFirst) { - this._smoothBrushLockY = null; - this._smoothBrushLastPos = null; - } - - // === Raycast — выбираем точку под курсором === - let hit = null; - let pickSource = ''; - if (!terrainEmpty) { - const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain); - hit = this.scene.pickWithRay( - this.scene.createPickingRay(this.scene.pointerX, this.scene.pointerY, null, this.camera), - pickPred, - ); - if (hit && hit.hit) { pickSource = 'terrain'; D.hitTerrain++; } - } - if (!hit || !hit.hit || !hit.pickedPoint) { - const groundPick = this.scene.pick(this.scene.pointerX, this.scene.pointerY, - (m) => m.name === 'editorGround'); - if (groundPick && groundPick.hit && groundPick.pickedPoint) { - hit = groundPick; - pickSource = 'ground'; - D.hitGround++; - } else { - D.hitNone++; - if (isFirst || tickN % 20 === 0) { - console.log(`[SmoothBrush] tick#${tickN} isFirst=${isFirst} → NO HIT (terrainEmpty=${terrainEmpty}, pointer=${this.scene.pointerX},${this.scene.pointerY})`); - } - return; - } - } - const worldPt = hit.pickedPoint; - - // === Инициализация пустого grid при первом клике sculpt/fill === - let initialized = false; - if (terrainEmpty) { - if (!isFirst) { - if (tickN <= 5 || tickN % 30 === 0) { - console.log(`[SmoothBrush] tick#${tickN} isFirst=false, terrainEmpty=true → skip (нужен первый клик)`); - } - return; - } - if (tool !== 'sculpt' && tool !== 'draw' && tool !== 'fill') { - console.log(`[SmoothBrush] tick#${tickN} terrainEmpty + tool='${tool}' → нельзя инициализировать (используйте sculpt/draw/fill)`); - return; - } - console.log(`[SmoothBrush] tick#${tickN} INIT empty grid (tool='${tool}', hit at ${worldPt.x.toFixed(1)},${worldPt.y.toFixed(1)},${worldPt.z.toFixed(1)})`); - this._initEmptySmoothTerrain(); - initialized = true; - if (!this._robloxTerrain || !this._robloxTerrain.grid) { - console.warn(`[SmoothBrush] tick#${tickN} init FAILED — grid still null`); - return; - } - } - - const material = this._terrainBrush?.material || 'grass'; - const radius = Math.max(3, (this._terrainBrush?.brushSize || 4) * 2.0); - // strength: slider 0..100 → реальная strength 60..400 для sculpt. - // Минимум 60 чтобы edge influence (~0.05) давал delta=3 → cells - // на краю кисти достигали threshold 128 за 40 тиков а не 128. - // Максимум 400 = мгновенно ставит cells в 255 (полная заливка). - const strengthSlider = this._terrainBrush?.strength ?? 50; - const strength = 60 + (strengthSlider / 100) * 340; - - let brushType; - if (shiftKey) { - brushType = 'sculptDown'; - } else { - switch (tool) { - case 'draw': brushType = 'sculptUp'; break; - case 'sculpt': brushType = 'sculptUp'; break; - case 'smooth': brushType = 'smooth'; break; - case 'paint': brushType = 'paint'; break; - case 'flatten': - if (isFirst) this._smoothFlattenTargetY = worldPt.y; - brushType = 'flatten'; - break; - case 'fill': brushType = 'fill'; break; - case 'erase': brushType = 'erase'; break; - // === Plant-кисти: добавление декораций === - case 'plantGrass': brushType = 'plantGrass'; break; - case 'plantFlower': brushType = 'plantFlower'; break; - case 'plantMushroom': brushType = 'plantMushroom'; break; - case 'plantTree': brushType = 'plantTree'; break; - default: brushType = 'sculptUp'; - } - } - - // === Plant-кисти обрабатываются ОТДЕЛЬНО от sculpt-логики === - // Они НЕ модифицируют DensityGrid — добавляют thin-instance модели. - if (brushType.startsWith('plant')) { - return this._smoothBrushTickPlant(brushType, worldPt, radius, shiftKey, isFirst); - } - - // === Plane-lock: для sculpt-кистей фиксируем Y первого клика === - // Без этого при drag в одной XZ-точке raycast возвращает всё более - // высокий Y (рельеф рос на прошлых тиках) → кисть смещается ВВЕРХ → - // рельеф летит на камеру ("какаха"). - // С lock'ом drag работает в горизонтальной плоскости фиксированной - // высоты первого клика, как в Roblox Studio. - const isSculptKind = brushType === 'sculptUp' || brushType === 'sculptDown'; - let centerY = worldPt.y; - if (isSculptKind) { - if (this._smoothBrushLockY === null) { - this._smoothBrushLockY = worldPt.y; - } - centerY = this._smoothBrushLockY; - // Смещение от плоскости первого клика на radius×0.5 - // (вверх для sculptUp, вниз для sculptDown). - if (brushType === 'sculptUp') centerY += radius * 0.5; - else centerY -= radius * 0.5; - } - - // === Rate-limit: пропускаем tick если кисть не сдвинулась далеко === - // Между тиками должно быть >= 0.6×radius по XZ. Это убивает feedback - // loop в одной точке. - if (isSculptKind && !isFirst && this._smoothBrushLastPos) { - const dx = worldPt.x - this._smoothBrushLastPos.x; - const dz = worldPt.z - this._smoothBrushLastPos.z; - const minDist = radius * 0.6; - if (dx * dx + dz * dz < minDist * minDist) { - // Кисть в той же точке — пропускаем (но НЕ для isFirst). - return; - } - } - if (isSculptKind) { - this._smoothBrushLastPos = { x: worldPt.x, z: worldPt.z }; - } - - const params = { - center: { x: worldPt.x, y: centerY, z: worldPt.z }, - radius, - strength, - material, - targetY: this._smoothFlattenTargetY, - }; - const tApply0 = performance.now(); - const built = this._robloxTerrain.applyBrushAndRebuild(brushType, params); - const tApply = performance.now() - tApply0; - if (built > 0) D.applyResult.built += built; - else D.applyResult.dirty0++; - - if (isFirst || tickN <= 5 || tickN % 20 === 0 || initialized || built === 0) { - const gridStats = this._robloxTerrain.grid - ? `solid=${this._robloxTerrain.grid.countSolid?.() ?? '?'}` - : 'no-grid'; - console.log( - `[SmoothBrush] tick#${tickN} ${brushType} slider=${strengthSlider} → strength=${strength.toFixed(0)} ` - + `pick='${pickSource}' @(${worldPt.x.toFixed(1)},${worldPt.y.toFixed(1)},${worldPt.z.toFixed(1)}) ` - + `r=${radius} mat=${material} ${gridStats} ` - + `→ built=${built} chunks in ${tApply.toFixed(0)}ms ` - + (initialized ? ' [INITIALIZED!]' : '') - + (built === 0 ? ' [NO CHANGE]' : ''), - ); - } - - try { this._onSceneChange?.(); } catch (e) {} - } - - /** - * Plant-кисть: расставляет/удаляет декорации (трава/цветы/грибы/деревья). - * Не трогает DensityGrid — работает только с SmoothDecoManager. - * Shift = ластик (удалить декорации в радиусе). - */ - _smoothBrushTickPlant(brushType, worldPt, radius, shiftKey, isFirst) { - // Нужен SmoothDecoManager (создаём lazy при первом plant-клике) - if (!this._smoothDecoManager) { - this._smoothDecoManager = new SmoothDecoManager(this.scene); - this._smoothDecoManager.loadAll(); - } - // Rate-limit как в sculpt: пропускаем близкие тики - if (!isFirst && this._smoothBrushLastPos) { - const dx = worldPt.x - this._smoothBrushLastPos.x; - const dz = worldPt.z - this._smoothBrushLastPos.z; - const minDist = radius * 0.4; // плотнее чем sculpt (декорации мелкие) - if (dx * dx + dz * dz < minDist * minDist) return; - } - this._smoothBrushLastPos = { x: worldPt.x, z: worldPt.z }; - - // Shift — ластик - if (shiftKey) { - const removed = this._smoothDecoManager.removeBrushDecoInRadius( - { x: worldPt.x, z: worldPt.z }, radius, - ); - if (removed > 0) { - console.log(`[SmoothBrush] erased ${removed} decorations at (${worldPt.x.toFixed(1)},${worldPt.z.toFixed(1)})`); - // Пересинхронизировать tree-colliders в physics - if (this.physics?.setSmoothDecoTrees) { - this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders()); - } - try { this._onSceneChange?.(); } catch (e) {} - } - return; - } - - // Маппинг brushType → kind для SmoothDecoManager - const kindMap = { - plantGrass: 'grass', - plantFlower: 'flower', - plantMushroom: 'mushroom', - plantTree: 'tree', - }; - const kind = kindMap[brushType]; - if (!kind) return; - - // Количество инстансов за один тик зависит от типа. - // Трава густая, деревья редко. - const countMap = { grass: 6, flower: 4, mushroom: 2, tree: 1 }; - const count = countMap[kind] || 3; - - // Surface-Y хелпер: raycast по smooth-terrain ИЛИ ground (y=0). - const sampleSurfaceY = (x, z) => { - if (this.physics?._sampleRobloxSurface) { - const y = this.physics._sampleRobloxSurface(x, z); - if (y !== null) return y; - } - // Fallback на ground y=0 - return 0; - }; - - const result = this._smoothDecoManager.addBrushDeco({ - kind, - center: { x: worldPt.x, y: worldPt.y, z: worldPt.z }, - radius, - count, - sampleSurfaceY, - }); - const added = result.added || 0; - if (added > 0) { - console.log(`[SmoothBrush] planted ${added} ${kind} at (${worldPt.x.toFixed(1)},${worldPt.z.toFixed(1)})`); - // Если посадили деревья — пересинхронизировать tree-colliders - // в physics (полная переустановка через getAllTreeColliders). - if (kind === 'tree' && this.physics?.setSmoothDecoTrees) { - this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders()); - } - try { this._onSceneChange?.(); } catch (e) {} - } - } - - /** Залить выделенный регион материалом. Используется инструментом - * «Заполнить» когда есть активный _terrainRegion. */ - _terrainFillRegion(matId) { - const r = this._terrainRegion; - const tm = this.terrainManager; - if (!r || !tm) return 0; - const minX = Math.min(r.x0, r.x1); - const maxX = Math.max(r.x0, r.x1); - const minY = Math.min(r.y0, r.y1); - const maxY = Math.max(r.y0, r.y1); - const minZ = Math.min(r.z0, r.z1); - const maxZ = Math.max(r.z0, r.z1); - let n = 0; - for (let x = minX; x <= maxX; x++) { - for (let y = minY; y <= maxY; y++) { - for (let z = minZ; z <= maxZ; z++) { - const key = `${x},${y},${z}`; - if (tm.voxels.has(key)) continue; - tm.setVoxel(x, y, z, matId); - n++; - } - } - } - return n; - } - - /** Переместить весь регион на dx/dy/dz в voxel-клетках. */ - _terrainMoveRegion(dx, dy, dz) { - const r = this._terrainRegion; - const tm = this.terrainManager; - if (!r || !tm) return 0; - if (dx === 0 && dy === 0 && dz === 0) return 0; - const minX = Math.min(r.x0, r.x1); - const maxX = Math.max(r.x0, r.x1); - const minY = Math.min(r.y0, r.y1); - const maxY = Math.max(r.y0, r.y1); - const minZ = Math.min(r.z0, r.z1); - const maxZ = Math.max(r.z0, r.z1); - // Собираем содержимое региона - const collected = []; - for (let x = minX; x <= maxX; x++) { - for (let y = minY; y <= maxY; y++) { - for (let z = minZ; z <= maxZ; z++) { - const m = tm.getVoxel(x, y, z); - if (!m) continue; - collected.push({ x, y, z, m }); - } - } - } - // Удаляем из старых позиций - for (const v of collected) tm.removeVoxel(v.x, v.y, v.z); - // Ставим в новые - let n = 0; - for (const v of collected) { - tm.setVoxel(v.x + dx, v.y + dy, v.z + dz, v.m); - n++; - } - // Сдвигаем сам регион - r.x0 += dx; r.x1 += dx; - r.y0 += dy; r.y1 += dy; - r.z0 += dz; r.z1 += dz; - this._updateTerrainRegionVisual(); - return n; - } - - /** Двигать preview-меш под курсор. Вызывается из mousemove. */ - _updateTerrainBrushPosition() { - if (this._activeTool !== 'terrain') return; - if (!this._terrainBrushPreview) return; - // === Smooth-режим: raycast по smooth-mesh, preview на surface === - if (this._terrainBrush?.terrainMode === 'smooth' && this._robloxTerrain?.grid) { - const ray = this.scene.createPickingRay( - this.scene.pointerX, this.scene.pointerY, - null, this.camera, - ); - const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain); - const hit = this.scene.pickWithRay(ray, pickPred); - if (hit && hit.hit && hit.pickedPoint) { - this._terrainBrushPreview.position.copyFrom(hit.pickedPoint); - } - return; - } - // Voxel-режим (как было) - const cell = this._pickTerrainCell(false); - if (!cell) return; - const S = TERRAIN_VOXEL_SIZE; - this._terrainBrushPreview.position.set( - (cell.x + 0.5) * S, - (cell.y + 0.5) * S, - (cell.z + 0.5) * S, - ); - } - - /** Публичный сеттер: выбрать тип блока для постановки. */ - setActiveBlockType(blockTypeId) { - this._activeBlockType = blockTypeId; - } - - /** Публичный сеттер: выбрать тип модели для постановки. */ - setActiveModelType(modelTypeId) { - this._activeModelType = modelTypeId; - } - - /** Тип примитива для постановки (cube/sphere/...). */ - setActivePrimitiveType(typeId) { - this._activePrimitiveType = typeId; - } - - getPrimitiveCount() { - return this.primitiveManager ? this.primitiveManager.getInstanceCount() : 0; - } - - /** Количество блоков (для status bar). */ - getBlockCount() { - return this.blockManager ? this.blockManager.count() : 0; - } - - /** Количество моделей-инстансов. */ - getModelCount() { - return this.modelManager ? this.modelManager.getInstanceCount() : 0; - } - - /** Подписаться на изменение выделения (UI / Inspector / Hierarchy). */ - setOnSelectionChange(cb) { - if (this.selection) { - // Объединяем со внутренней подпиской на gizmo - this.selection.setOnSelectionChange((sel) => { - this._updateGizmoForSelection(sel); - if (cb) cb(sel); - }); - } - } - - /** Текущее выделение (или null). */ - getSelection() { - return this.selection?.getSelection() || null; - } - - /** Выделить блок программно (например по клику в Hierarchy). */ - selectBlockAt(x, y, z) { - this.selection?.selectBlockAt(x, y, z); - } - - /** Выделить модель программно. */ - selectModelByInstanceId(id) { - this.selection?.selectModelByInstanceId(id); - } - - /** Снять выделение. */ - clearSelection() { - this.selection?.clear(); - } - - /** Удалить выделенный объект. */ - deleteSelected() { - this.selection?.deleteSelected(); - } - - /** - * Дублировать выделенный объект (Ctrl+D). - * Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y). - * Модель: создаёт копию со смещением +1 по X. - */ - duplicateSelected() { - const sel = this.selection?.getSelection(); - if (!sel) return; - if (sel.type === 'block') { - // Ищем свободную клетку рядом - const candidates = [ - [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1], [0, 1, 0], - ]; - for (const [dx, dy, dz] of candidates) { - const nx = sel.gridX + dx, ny = sel.gridY + dy, nz = sel.gridZ + dz; - if (ny < 0) continue; - if (!this.blockManager.hasBlock(nx, ny, nz)) { - this.blockManager.addBlock(nx, ny, nz, sel.blockTypeId); - this.selection.selectBlockAt(nx, ny, nz); - return; - } - } - } else if (sel.type === 'model') { - // Сохраняем все нужные поля выделения до того как промис завершится - // (selection может перезатереться к моменту resolve) - const typeId = sel.modelTypeId; - const sx = sel.x, sy = sel.y, sz = sel.z; - const rotY = sel.rotationY || 0; - this.modelManager.addInstance(typeId, sx + 1, sy, sz, rotY) - .then(newId => { - if (newId != null) this.selection?.selectModelByInstanceId(newId); - }) - .catch(err => { - // eslint-disable-next-line no-console - console.error('[BabylonScene] duplicate model error:', err); - }); - } else if (sel.type === 'userModel') { - const typeId = sel.userModelTypeId; - const sx = sel.x, sy = sel.y, sz = sel.z; - const rotY = sel.rotationY || 0; - this.userModelManager.addInstance(typeId, sx + 1, sy, sz, rotY, { - currentUserId: this._currentUserId || null, - }).then(newId => { - if (newId != null) this.selection?.selectUserModelByInstanceId(newId); - }).catch(err => { - console.error('[BabylonScene] duplicate user model error:', err); - }); - } else if (sel.type === 'primitive') { - const newId = this.primitiveManager.addInstance(sel.primitiveType, { - x: sel.x + 1, y: sel.y, z: sel.z, - sx: sel.sx, sy: sel.sy, sz: sel.sz, - color: sel.color, material: sel.material, - canCollide: sel.canCollide, visible: sel.visible, - anchored: sel.anchored, - // Копируем и спец-свойства: текстуру, параметры лампы/эмиттера. - textureAsset: sel.textureAsset || null, - brightness: sel.brightness, range: sel.range, effect: sel.effect, - }); - if (newId != null) this.selection.selectPrimitiveById(newId); - } - } - - /** - * Скопировать выделенный объект в буфер обмена (Ctrl+C, Фаза 5.10). - * Буфер — localStorage, поэтому переживает перезагрузку страницы - * и смену проекта (Copy/Paste между проектами). - */ - copySelected() { - const sel = this.selection?.getSelection(); - if (!sel) return; - let clip = null; - if (sel.type === 'block') { - clip = { kind: 'block', blockTypeId: sel.blockTypeId }; - } else if (sel.type === 'model') { - clip = { - kind: 'model', modelTypeId: sel.modelTypeId, - rotationY: sel.rotationY || 0, scale: sel.scale || 1, - }; - } else if (sel.type === 'userModel') { - clip = { - kind: 'userModel', userModelTypeId: sel.userModelTypeId, - rotationY: sel.rotationY || 0, - }; - } else if (sel.type === 'primitive') { - clip = { - kind: 'primitive', primitiveType: sel.primitiveType, - sx: sel.sx, sy: sel.sy, sz: sel.sz, - color: sel.color, material: sel.material, - canCollide: sel.canCollide, visible: sel.visible, - anchored: sel.anchored, - textureAsset: sel.textureAsset || null, - brightness: sel.brightness, range: sel.range, effect: sel.effect, - }; - } - if (clip) { - try { localStorage.setItem('kubikon_clipboard', JSON.stringify(clip)); } - catch (e) { /* ignore — приватный режим / переполнение */ } - } - } - - /** - * Вставить объект из буфера обмена (Ctrl+V, Фаза 5.10). - * Объект появляется у точки, куда смотрит редактор-камера. - */ - pasteFromClipboard() { - let clip; - try { - const raw = localStorage.getItem('kubikon_clipboard'); - if (!raw) return; - clip = JSON.parse(raw); - } catch (e) { return; } - if (!clip || !clip.kind) return; - // Точка вставки — перед редактор-камерой (~6м по направлению взгляда). - const cam = this.camera; - let px = 0, py = 1, pz = 0; - if (cam) { - const fwd = cam.getForwardRay ? cam.getForwardRay().direction : null; - if (fwd) { - px = cam.position.x + fwd.x * 6; - pz = cam.position.z + fwd.z * 6; - } - } - if (clip.kind === 'block') { - const gx = Math.round(px), gz = Math.round(pz); - let gy = 0; - while (gy < 64 && this.blockManager?.hasBlock(gx, gy, gz)) gy++; - this.blockManager?.addBlock(gx, gy, gz, clip.blockTypeId); - this.selection?.selectBlockAt(gx, gy, gz); - } else if (clip.kind === 'model') { - this.modelManager?.addInstance(clip.modelTypeId, px, py, pz, clip.rotationY || 0) - .then(id => { if (id != null) this.selection?.selectModelByInstanceId(id); }) - .catch(() => {}); - } else if (clip.kind === 'userModel') { - this.userModelManager?.addInstance( - clip.userModelTypeId, px, py, pz, clip.rotationY || 0, - { currentUserId: this._currentUserId || null }, - ).then(id => { if (id != null) this.selection?.selectUserModelByInstanceId(id); }) - .catch(() => {}); - } else if (clip.kind === 'primitive') { - const id = this.primitiveManager?.addInstance(clip.primitiveType, { - x: px, y: Math.max(py, (clip.sy || 1) / 2), z: pz, - sx: clip.sx, sy: clip.sy, sz: clip.sz, - color: clip.color, material: clip.material, - canCollide: clip.canCollide, visible: clip.visible, - anchored: clip.anchored, - textureAsset: clip.textureAsset || null, - brightness: clip.brightness, range: clip.range, effect: clip.effect, - }); - if (id != null) this.selection?.selectPrimitiveById(id); - } - if (this._onSceneChange) this._onSceneChange(); - } - - /** - * Поставить выделенный объект на пол (y = 0). - * Для блоков — gridY=0. Для моделей — нижняя граница на полу. Для примитивов — sy/2. - */ - alignSelectedToFloor() { - const sel = this.selection?.getSelection(); - if (!sel) return; - if (sel.type === 'block') { - this.selection.moveSelectedBlock(sel.gridX, 0, sel.gridZ); - } else if (sel.type === 'model') { - // Модель: rootMesh.position.y = низ модели. y=0 = низ на полу. - this.selection.moveSelectedModel(sel.x, 0, sel.z); - } else if (sel.type === 'userModel') { - this.selection.moveSelectedUserModel(sel.x, 0, sel.z); - } else if (sel.type === 'primitive') { - // Центр примитива должен быть на высоте sy/2 чтобы низ касался пола. - const halfH = (sel.sy || 1) / 2; - this.selection.moveSelectedPrimitive(sel.x, halfH, sel.z); - } - } - - /** - * View-preset — поставить редактор-камеру в одну из стандартных позиций. - * preset: 'top' | 'front' | 'side' | 'iso' - */ - setViewPreset(preset) { - if (!this.camera) return; - const presets = { - top: { pos: [0, 40, 0.01], rot: [Math.PI / 2, 0, 0] }, // прямо сверху - front: { pos: [0, 8, -25], rot: [0, 0, 0] }, // спереди - side: { pos: [25, 8, 0], rot: [0, -Math.PI / 2, 0] }, // сбоку - iso: { pos: [15, 15, -20], rot: [Math.PI / 5, -Math.PI / 5, 0] }, // изометрия - }; - const p = presets[preset]; - if (!p) return; - this.camera.position = new Vector3(p.pos[0], p.pos[1], p.pos[2]); - this.camera.rotation = new Vector3(p.rot[0], p.rot[1], p.rot[2]); - } - - /** - * Поставить точку спавна там где сейчас смотрит редактор-камера - * (полезно для размещения «тут начинать игру»). - * spawnPoint = (camera.x, max(0, floor(camera.y) - 1), camera.z). - */ - setSpawnAtCamera() { - if (!this.camera) return; - const p = this.camera.position; - this._spawnPoint = { - x: Math.round(p.x), - y: Math.max(0, Math.floor(p.y) - 1), - z: Math.round(p.z), - }; - this._updateSpawnMarker(); - this.history?.markChange(); - if (this._onSceneChange) this._onSceneChange(); - } - - /** Изменить позицию выделенного (используется Inspector). */ - 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; - // Сброс состояния касаний — каждый прогон начинается «не касаясь». - if (this._touchState) this._touchState.clear(); - this._playerMenuOpen = false; // меню-оверлей закрыт на старте Play - // По умолчанию стандартный HUD видим в Play. - // Скрипт может скрыть через game.hud.setVisible(false). - this._setStdHudVisible(true); - - // Включаем picking voxel-террейна — иначе камера _clampCameraToWorld - // не «видит» воксели в Ray-каст и пролетает сквозь стены. - try { this.terrainManager?.enablePickingForCamera?.(true); } catch (e) {} - - // Снимок редактор-камеры - this._editorCameraSnapshot = { - position: this.camera.position.clone(), - rotation: this.camera.rotation.clone(), - }; - - if (this._ghostMesh) this._ghostMesh.setEnabled(false); - this._setSpawnMarkerVisible(false); - // Триггеры — невидимые в Play, видимые в редакторе - this.primitiveManager?.setTriggersVisible(false); - - // Запоминаем исходные позиции unanchored-объектов чтобы вернуть - // их при выходе из Play (физика двигает mesh.position). - this._snapshotDynamicObjects(); - // Полный снимок примитивов и моделей — чтобы при Stop откатить - // ВСЕ изменения скриптов (удаления, цвет, видимость, повороты). - this._snapshotFullScene(); - - // Запускаем физику unanchored - this.dynamics?.start(); - - // Запускаем фоновую музыку и амбиент - this.audioManager?.start(); - - // Создаём PlayerController и стартуем - this.player = new PlayerController(this.scene, this.canvas, this.physics, this); - this.player.setModelType(this._playerModelType); - // Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck - try { - this.modalManager?.attachPlayer?.(this.player); - this.modalManager?.attachAudio?.(this.audioManager); - } catch (e) {} - this.player._jumpPowerMul = this._jumpPowerMul ?? 1; - // Применяем дефолтную камеру если задана в сцене - if (this._defaultCameraMode && this._defaultCameraMode !== this.player._cameraMode) { - this.player._cameraMode = this._defaultCameraMode; - } - // На тач-устройствах отключаем pointer-lock и mouse-камеру - if (this._touchMode) this.player.setTouchMode(true); - this.player.setOnExitRequest(() => { - // Задача 07: магазин скинов открыт → Esc закрывает его (приоритет выше модала). - if (this._skinShop?.open) { - this._closeSkinShop(); - return; - } - // Задача 04: если открыт модал — первый Esc закрывает его, - // второй Esc уже выходит из Play. Так юзер не теряет состояние игры - // случайно при попытке скрыть модал. - if (this.modalManager?.isOpen?.()) { - this.modalManager.close(); - return; - } - // ESC в плеере = TOGGLE меню-оверлея поверх ЖИВОЙ игры (как в Roblox). - // Единый источник истины — _playerMenuOpen в движке. Раньше состояние - // меню держал React, а ESC слушали ДВА обработчика (движок + React) → - // гонка: меню открывалось поверх меню, а _uiCursorMode застревал в true - // → orbit-камера по ПКМ переставала работать после закрытия меню. - // Теперь движок сам решает open/close и шлёт это в _onEscMenu(open). - if (typeof this._onEscMenu === 'function') { - if (this._playerMenuOpen) { - // Меню открыто → ESC закрывает: вернуть мышь в игру. - this._playerMenuOpen = false; - this.player?.setUiCursorMode?.(false); - this._onEscMenu(false); - } else { - // Меню закрыто → ESC открывает: освободить курсор. - this._playerMenuOpen = true; - this.player?.setUiCursorMode?.(true); - this._onEscMenu(true); - } - return; - } - // Фолбэк (если меню не подписано, напр. в студии) — старое поведение. - this.exitPlayMode(); - if (this._onPlayChange) this._onPlayChange(false); - }); - if (this._onPlayerHpChange) this.player.setOnHpChange(this._onPlayerHpChange); - if (this._onPlayerDeath) this.player.setOnDeath(this._onPlayerDeath); - this.player.start(this._spawnPoint); - - // Запускаем пользовательские скрипты (этап 2.1). - // Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену, - // поэтому скрипты стартуем в следующем кадре. - this.gameRuntime = new GameRuntime(this); - try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {} - // Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime. - // ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным - // this.audioManager (AudioManager — ambient/music для всех проектов). - if (!this.gameAudioManager) { - this.gameAudioManager = new GameAudioManager(); - } - // GD-уровень (Этап 5.1): автоматически обрабатывает GD-порталы, шипы, финиш, монеты. - // Юзер просто ставит объекты из палитры (категории "GD-порталы" и "GD-объекты") в редакторе. - if (!this.gdLevelManager) { - this.gdLevelManager = new GdLevelManager(this); - this.gdLevelManager.setOnPortalEnter((newMode, prevMode) => { - try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdPortalEnter', data: { newMode, prevMode } }); } catch (e) {} - try { this.gameAudioManager?.playSfx?.('flip'); } catch (e) {} - }); - this.gdLevelManager.setOnDeath((info) => { - try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdDeath', data: info }); } catch (e) {} - try { this.gameAudioManager?.playSfx?.('death'); } catch (e) {} - // Респавн игрока через teleport на spawnPoint - try { - const sp = this._spawnPoint || { x: 0, y: 2, z: 0 }; - this.player.teleport(sp.x, sp.y, sp.z); - // Сбросить vy чтобы не нести инерцию из шипа - if (this.player) this.player._vy = 0; - } catch (e) {} - }); - this.gdLevelManager.setOnFinish((info) => { - const stats = this.gdLevelManager.getCoinsStats(); - try { - this.gameRuntime?.routeGlobalEvent?.('message', { - name: 'gdFinish', - data: { ...info, coinsCollected: stats.collected, coinsTotal: stats.total }, - }); - } catch (e) {} - try { this.gameAudioManager?.playSfx?.('level_complete'); } catch (e) {} - }); - this.gdLevelManager.setOnCoinCollected((info) => { - try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdCoinCollected', data: info }); } catch (e) {} - try { this.gameAudioManager?.playSfx?.('coin'); } catch (e) {} - }); - } - this.gdLevelManager.start(); - // Этапы G1/G2: skybox+параллакс+декоративная трава для GD-уровней. - // Откладываем на setTimeout — primitiveManager.instances наполняется - // не сразу при enterPlayMode (load.primitives асинхронный). - // GD-проект определяется флагом settings.isGd (см. serialize/loadFromState). - // Fallback для старых проектов БЕЗ флага — реальные id GD-уровней: - // - 295: GD 2.0 sandbox - // - 296..306: L1-L11 (эпоха 1 + L11 легаси) - // - 350..358: L12-L20 (эпоха 2) - // Раньше было только 296..315 — L12-L20 (id 350..358) НЕ попадали, - // и веб-плеер не активировал GD-инфру (шипы конусы вместо .glb, - // нет skybox/forest на заднем фоне). На APK работало правильно. - const _pid = Number(this._currentProjectId); - const isGd = (typeof this._isGdProject === 'boolean') - ? this._isGdProject - : ((_pid >= 296 && _pid <= 315) - || (_pid >= 350 && _pid <= 358) - || _pid === 295); - console.log(`[GD-gfx] currentProjectId=${this._currentProjectId}, isGd=${isGd}, flag=${this._isGdProject}`); - if (isGd) { - // Ширина уровня — по самому правому cube-блоку - let levelWidth = 1000; - if (this.blockManager && this.blockManager.blocks) { - for (const key of this.blockManager.blocks.keys()) { - const x = parseInt(String(key).split(',')[0], 10); - if (Number.isFinite(x) && x > levelWidth) levelWidth = x; - } - } - setTimeout(() => { - try { - if (!this.gdSkybox) { - this.gdSkybox = new GdSkybox(); - const cam = this.player?.camera || this.scene.activeCamera; - this.gdSkybox.attach(this.scene, cam); - console.log('[GD-gfx] skybox attached'); - } - if (!this.gdGroundSkin) { - this.gdGroundSkin = new GdGroundSkin(); - this.gdGroundSkin.attach(this.scene, levelWidth, this._shadowGenerator, this); - console.log('[GD-gfx] groundSkin attached, width=', levelWidth); - } - // Эпоха по project_id. L11 = 306 (легаси). L12-L20 = 350-358. - const pid = Number(this._currentProjectId) || 296; - const GD_PID_TO_EPOCH = { - 296:1, 297:1, 298:1, 299:1, 300:1, 301:1, 302:1, 303:1, 304:1, 305:1, - 306:2, 350:2, 351:2, 352:2, 353:2, 354:2, 355:2, 356:2, 357:2, 358:2, - }; - const epoch = GD_PID_TO_EPOCH[pid] || 1; - if (!this.gdSpikes) { - this.gdSpikes = new GdSpikes(); - this.gdSpikes.attach(this.scene, this, epoch); - } - if (!this.gdStartArch) { - this.gdStartArch = new GdStartArch(); - this.gdStartArch.attach(this.scene, epoch); - } - if (!this.gdPortalArch) { - this.gdPortalArch = new GdPortalArch(); - this.gdPortalArch.attach(this.scene, this, this._currentUserId); - } - if (!this.gdDiamond) { - this.gdDiamond = new GdDiamond(); - this.gdDiamond.attach(this.scene, this); - } - if (!this.gdFinish) { - this.gdFinish = new GdFinish(); - this.gdFinish.attach(this.scene, this, epoch); - } - if (!this.gdForest) { - this.gdForest = new GdForest(); - this.gdForest.attach(this.scene, levelWidth, epoch); - } - if (!this.gdPlayerCube) { - this.gdPlayerCube = new GdPlayerCube(); - this.gdPlayerCube.attach(this.scene, this); - } - if (!this.gdPlayerModeSkin) { - // Задержка 600мс — даём скрипту уровня применить базовый cube-skin, - // чтобы _origTexture при первой смене режима содержала правильную текстуру. - setTimeout(() => { - this.gdPlayerModeSkin = new GdPlayerModeSkin(); - this.gdPlayerModeSkin.attach(this.scene, this, this._currentUserId); - }, 600); - } - if (!this.gdPlayerTrail) { - this.gdPlayerTrail = new GdPlayerTrail(); - this.gdPlayerTrail.attach(this.scene, this, this._currentProjectId, this._currentUserId); - } - if (!this.gdPostFx) { - this.gdPostFx = new GdPostFx(); - const cam = this.player?.camera || this.scene.activeCamera; - this.gdPostFx.attach(this.scene, cam, this); - } - // Тени отключены — делаем через GdGroundSkin (fake shadows) - this._enableGdShadows(); - } catch (e) { console.warn('[BabylonScene] GD-graphics attach failed', e); } - }, 50); - } - if (this._onScriptLog) this.gameRuntime.setOnLog(this._onScriptLog); - if (this._onScriptHud) this.gameRuntime.setOnHud(this._onScriptHud); - if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair); - // eslint-disable-next-line no-console - console.log('[BabylonScene] enterPlayMode — scripts to run:', this._scripts?.length || 0, this._scripts); - // Старт через requestAnimationFrame — даём Babylon собрать сцену - requestAnimationFrame(() => { - if (this._isPlaying) this.gameRuntime?.start(this._scripts || []); - }); - - // === Оружие === - if (!this.weapons) this.weapons = new WeaponSystem(this); - if (this._onAmmoChange) this.weapons.setOnAmmoChange(this._onAmmoChange); - // Подключаем зомби-логику к попаданиям пули - this.weapons.setOnHit((hit) => { - if (hit?.mesh && this.zombieManager) { - this.zombieManager.damageByMesh(hit.mesh, hit.damage || 25); - } - if (this._onWeaponHit) { - try { this._onWeaponHit(hit); } catch (e) {} - } - }); - this.weapons.start(); - - // === Зомби-система === - if (!this.zombieManager) this.zombieManager = new ZombieManager(this); - if (!this.spawnerManager) this.spawnerManager = new ZombieSpawnerManager(this, this.zombieManager); - this.zombieManager.start(); - this.spawnerManager.start(); - - // === NPC-система (Фаза 4.1) — управляемые скриптом персонажи === - if (!this.npcManager) this.npcManager = new NpcManager(this); - this.npcManager.start(); - - // === Связи объектов (Фаза 5, Constraints) === - if (!this.constraintManager) this.constraintManager = new ConstraintManager(this); - this.constraintManager.start(); - - // === Лучи и следы (Фаза 5.2 — Beam/Trail) === - if (!this.beamManager) this.beamManager = new BeamManager(this); - this.beamManager.start(); - // Задача 08: активируем pointer-примитивы из палитры в реальные стрелки. - this._activatePointers(); - - // === 3D-звук (Фаза 5.5 — позиционный звук) === - if (!this.soundManager) this.soundManager = new SoundManager(this); - this.soundManager.start(); - // Регистрируем gameplay-объекты: зомби и спавнеры. - // Применяем defaults + текущие gameplayParams из инспектора. - if (this.modelManager) { - for (const data of this.modelManager.instances.values()) { - const gp = data.gameplay; - if (!gp) continue; - const params = { ...(gp.defaultParams || {}), ...(data.gameplayParams || {}) }; - if (gp.isZombie) { - this.zombieManager.registerExisting(data.instanceId, params); - } else if (gp.isZombieSpawner) { - this.spawnerManager.register(data.instanceId, params); - } - } - } - // Снаряжаем оружие из активного слота инвентаря - const active = this.inventory?.getActive?.(); - if (active) this.weapons.equip(active); - - // Замораживаем world-matrix у всех статичных GLB-моделей - // (не зомби и не спавнеры). Деревья, дома, камни не двигаются — - // Babylon не должен пересчитывать их матрицы каждый кадр. - try { this.modelManager?.freezeStaticModels?.(); } catch (e) {} - try { this.primitiveManager?.freezeStaticPrimitives?.(); } catch (e) {} - - // ОПТИМИЗАЦИЯ ОТКЛЮЧЕНА: octree селекшн. - // Octree создаётся один раз и не «знает» о мешах добавленных позже — - // даже с alwaysSelectAsActiveMesh новые меши (трейсеры выстрелов, - // debris-кубы при смерти, динамические объекты) фактически выпадают - // из активного списка → невидимы. Стандартный frustum-culling Babylon - // дешёвый сам по себе для нашей сцены, octree больше вреда чем пользы. - } - - /** Заглушка для совместимости — раньше пересоздавала octree. */ - setActiveMeshesDirty() { - // no-op - } - - /** Установить колбэк логов от скриптов (для Console-панели UI). */ - setOnScriptLog(cb) { - this._onScriptLog = cb; - if (this.gameRuntime) this.gameRuntime.setOnLog(cb); - } - - /** Колбэк команд HUD от скриптов (для GameHud React-компонента). */ - setOnScriptHud(cb) { - this._onScriptHud = cb; - if (this.gameRuntime) this.gameRuntime.setOnHud(cb); - } - - /** Колбэк смены прицела из скрипта (game.player.crosshair = 'cross'). */ - setOnScriptCrosshair(cb) { - this._onScriptCrosshair = cb; - if (this.gameRuntime) this.gameRuntime.setOnCrosshairChange(cb); - } - - // ============================================================ - // Таймер прохождения (для лидерборда) - // ============================================================ - /** cb({state: 'start'|'stop'|'submit', timeMs}) */ - setOnTimer(cb) { this._onTimer = cb; } - - _timerStart() { - this._timerStartedAt = performance.now(); - this._timerRunning = true; - if (this._onTimer) try { this._onTimer({ state: 'start', timeMs: 0 }); } catch (e) {} - } - _timerStop() { - if (!this._timerRunning) return; - const ms = Math.round(performance.now() - (this._timerStartedAt || 0)); - this._timerRunning = false; - if (this._onTimer) try { this._onTimer({ state: 'stop', timeMs: ms }); } catch (e) {} - } - _timerSubmit() { - if (!this._timerRunning && !this._timerStartedAt) return; - const ms = Math.round(performance.now() - (this._timerStartedAt || 0)); - this._timerRunning = false; - if (this._onTimer) try { this._onTimer({ state: 'submit', timeMs: ms }); } catch (e) {} - } - /** Получить текущее время таймера в мс (или 0 если не запущен). */ - getTimerMs() { - if (!this._timerRunning || !this._timerStartedAt) return 0; - return Math.round(performance.now() - this._timerStartedAt); - } - isTimerRunning() { return !!this._timerRunning; } - - /** PERF-METRICS: получить и сбросить накопленные метрики за окно. */ - flushPerfMetrics() { - const m = this._perfMetrics; - if (!m) return null; - const out = { - render_ms_avg: m.render_count ? (m.render_ms_sum / m.render_count) : 0, - physics_ms_avg: m.physics_count ? (m.physics_ms_sum / m.physics_count) : 0, - script_ms_avg: m.script_count ? (m.script_ms_sum / m.script_count) : 0, - idle_ms_avg: m.idle_count ? (m.idle_ms_sum / m.idle_count) : 0, - render_count: m.render_count, - physics_count: m.physics_count, - script_count: m.script_count, - }; - m.render_ms_sum = 0; m.render_count = 0; - m.physics_ms_sum = 0; m.physics_count = 0; - m.script_ms_sum = 0; m.script_count = 0; - m.idle_ms_sum = 0; m.idle_count = 0; - return out; - } - - /** - * Поставить render-loop на паузу. - * Используется когда Babylon canvas не виден (активен таб скрипта), - * чтобы освободить CPU/GPU и Monaco не лагал. - * НЕ останавливает Play-режим — только рендер. - */ - pauseRendering() { this._renderingPaused = true; } - resumeRendering() { this._renderingPaused = false; } - isRenderingPaused() { return !!this._renderingPaused; } - - /** - * Создать эффект частиц в точке. Вызывается из GameRuntime._spawnParticles - * (через game.scene.spawnParticles в скриптах). - * - * payload: { type, position: {x,y,z}, duration, count, color } - */ - _spawnParticleEffect(payload) { - if (!payload || !this.scene) return; - const pos = payload.position || { x: 0, y: 0, z: 0 }; - const type = payload.type || 'sparks'; - const duration = Math.max(0.1, Math.min(20, Number(payload.duration) || 1.5)); - const countMul = Math.max(0.1, Math.min(10, Number(payload.count) || 1)); - - // Кэшируем текстуру частицы — один раз на сцену - if (!this._particleTex) { - const tex = new DynamicTexture('particleTex', { width: 64, height: 64 }, this.scene, false); - const ctx = tex.getContext(); - const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); - grad.addColorStop(0, 'rgba(255,255,255,1)'); - grad.addColorStop(0.4, 'rgba(255,255,255,0.6)'); - grad.addColorStop(1, 'rgba(255,255,255,0)'); - ctx.fillStyle = grad; - ctx.fillRect(0, 0, 64, 64); - tex.update(); - tex.hasAlpha = true; - this._particleTex = tex; - } - - // MOBILE-OPT (этап 1): на мобильном уменьшаем кол-во частиц в 2 раза - const baseCount = this._isMobileMode ? 40 : 80; - const ps = new ParticleSystem('p_' + Date.now(), - Math.floor(baseCount * countMul), this.scene); - ps.particleTexture = this._particleTex; - ps.emitter = new Vector3(pos.x, pos.y, pos.z); - ps.minEmitBox = new Vector3(-0.1, -0.1, -0.1); - ps.maxEmitBox = new Vector3(0.1, 0.1, 0.1); - ps.blendMode = ParticleSystem.BLENDMODE_ADD; - - const customColor = payload.color && /^#[0-9a-fA-F]{6}$/.test(payload.color) - ? payload.color : null; - this._configureParticleSystem(ps, type, customColor, countMul); - - ps.start(); - // Авто-стоп: для explosion почти сразу (это burst), для остальных = duration - const stopAt = type === 'explosion' ? 0.05 : duration; - const disposeAt = stopAt + (ps.maxLifeTime || 1) + 0.3; - setTimeout(() => { try { ps.stop(); } catch (e) {} }, stopAt * 1000); - // dispose(false) — particleTexture расшарена (_particleTex), не удалять. - setTimeout(() => { try { ps.dispose(false); } catch (e) {} }, disposeAt * 1000); - } - - /** - * Настроить параметры ParticleSystem под тип эффекта. - * Общий конфигуратор для разового эффекта (_spawnParticleEffect) и - * постоянного эмиттера-объекта (createEmitterParticles). - */ - _configureParticleSystem(ps, type, customColor, countMul = 1) { - const hexToColor4 = (hex, a = 1) => { - const r = parseInt(hex.substr(1, 2), 16) / 255; - const g = parseInt(hex.substr(3, 2), 16) / 255; - const b = parseInt(hex.substr(5, 2), 16) / 255; - return new Color4(r, g, b, a); - }; - - switch (type) { - case 'fire': - ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.6, 0.1, 1); - ps.color2 = customColor ? hexToColor4(customColor, 0.8) : new Color4(1, 0.2, 0, 1); - ps.colorDead = new Color4(0.2, 0, 0, 0); - ps.minSize = 0.2; ps.maxSize = 0.5; - ps.minLifeTime = 0.4; ps.maxLifeTime = 1.0; - ps.emitRate = 80; - ps.gravity = new Vector3(0, 1.2, 0); - ps.direction1 = new Vector3(-0.4, 1.5, -0.4); - ps.direction2 = new Vector3(0.4, 2.0, 0.4); - ps.minEmitPower = 0.5; ps.maxEmitPower = 1.2; - break; - case 'smoke': - ps.color1 = new Color4(0.4, 0.4, 0.4, 0.6); - ps.color2 = new Color4(0.2, 0.2, 0.2, 0.4); - ps.colorDead = new Color4(0, 0, 0, 0); - ps.minSize = 0.4; ps.maxSize = 1.2; - ps.minLifeTime = 1.5; ps.maxLifeTime = 3; - ps.emitRate = 40; - ps.gravity = new Vector3(0, 0.5, 0); - ps.direction1 = new Vector3(-0.3, 1, -0.3); - ps.direction2 = new Vector3(0.3, 1.5, 0.3); - ps.minEmitPower = 0.3; ps.maxEmitPower = 0.7; - ps.blendMode = ParticleSystem.BLENDMODE_STANDARD; - break; - case 'sparks': - ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 1, 0.4, 1); - ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.6, 0, 1); - ps.colorDead = new Color4(0.5, 0.2, 0, 0); - ps.minSize = 0.05; ps.maxSize = 0.15; - ps.minLifeTime = 0.3; ps.maxLifeTime = 0.8; - ps.emitRate = 200; - ps.gravity = new Vector3(0, -8, 0); - ps.direction1 = new Vector3(-3, 4, -3); - ps.direction2 = new Vector3(3, 7, 3); - ps.minEmitPower = 1; ps.maxEmitPower = 3; - break; - case 'magic': - ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(0.6, 0.3, 1, 1); - ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(0.3, 0.6, 1, 1); - ps.colorDead = new Color4(0.2, 0, 0.5, 0); - ps.minSize = 0.15; ps.maxSize = 0.35; - ps.minLifeTime = 1; ps.maxLifeTime = 2.2; - ps.emitRate = 60; - ps.gravity = new Vector3(0, 0.3, 0); - ps.direction1 = new Vector3(-1, 1, -1); - ps.direction2 = new Vector3(1, 2, 1); - ps.minEmitPower = 0.5; ps.maxEmitPower = 1.5; - break; - case 'explosion': - ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.7, 0.2, 1); - ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.3, 0, 1); - ps.colorDead = new Color4(0.2, 0, 0, 0); - ps.minSize = 0.3; ps.maxSize = 0.8; - ps.minLifeTime = 0.4; ps.maxLifeTime = 1.0; - ps.emitRate = 0; - ps.manualEmitCount = Math.floor(120 * countMul); - ps.gravity = new Vector3(0, -3, 0); - ps.direction1 = new Vector3(-5, -1, -5); - ps.direction2 = new Vector3(5, 5, 5); - ps.minEmitPower = 2; ps.maxEmitPower = 6; - break; - case 'confetti': - ps.color1 = new Color4(1, 0.3, 0.3, 1); - ps.color2 = new Color4(0.3, 0.6, 1, 1); - ps.colorDead = new Color4(0, 0, 0, 0); - ps.minSize = 0.1; ps.maxSize = 0.25; - ps.minLifeTime = 1.5; ps.maxLifeTime = 3; - ps.emitRate = 100; - ps.gravity = new Vector3(0, -3, 0); - ps.direction1 = new Vector3(-3, 5, -3); - ps.direction2 = new Vector3(3, 8, 3); - ps.minEmitPower = 1; ps.maxEmitPower = 3; - break; - default: - ps.color1 = new Color4(1, 1, 1, 1); - ps.color2 = new Color4(0.7, 0.7, 0.7, 0.5); - ps.colorDead = new Color4(0, 0, 0, 0); - ps.minSize = 0.1; ps.maxSize = 0.3; - ps.minLifeTime = 0.5; ps.maxLifeTime = 1.5; - ps.emitRate = 60; - ps.direction1 = new Vector3(-1, 1, -1); - ps.direction2 = new Vector3(1, 2, 1); - } - } - - /** - * Создать ПОСТОЯННУЮ систему частиц для эмиттера-объекта (костёр и т.п.). - * Не имеет авто-стопа — горит пока объект существует. Возвращает ps. - */ - createEmitterParticles(type, position, color) { - if (!this.scene) return null; - if (!this._particleTex) { - const tex = new DynamicTexture('particleTex', { width: 64, height: 64 }, this.scene, false); - const ctx = tex.getContext(); - const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); - grad.addColorStop(0, 'rgba(255,255,255,1)'); - grad.addColorStop(0.4, 'rgba(255,255,255,0.6)'); - grad.addColorStop(1, 'rgba(255,255,255,0)'); - ctx.fillStyle = grad; - ctx.fillRect(0, 0, 64, 64); - tex.update(); - tex.hasAlpha = true; - this._particleTex = tex; - } - const baseCount = this._isMobileMode ? 60 : 120; - const ps = new ParticleSystem('emitter_' + Date.now(), baseCount, this.scene); - ps.particleTexture = this._particleTex; - ps.emitter = new Vector3(position.x, position.y, position.z); - ps.minEmitBox = new Vector3(-0.15, -0.1, -0.15); - ps.maxEmitBox = new Vector3(0.15, 0.1, 0.15); - ps.blendMode = ParticleSystem.BLENDMODE_ADD; - const customColor = color && /^#[0-9a-fA-F]{6}$/.test(color) ? color : null; - // explosion как постоянный эффект не имеет смысла → fire - const effType = type === 'explosion' ? 'fire' : type; - this._configureParticleSystem(ps, effType, customColor, 1); - ps.start(); - return ps; - } - - /** - * Запустить ОДИН скрипт без Play-режима (отладочный запуск из редактора). - * Если runtime уже есть — переиспользуем, иначе создаём. - */ - startSoloScript(scriptId) { - const all = this._scripts || []; - const sc = all.find(s => s.id === scriptId); - if (!sc) return false; - if (!this.gameRuntime) { - this.gameRuntime = new GameRuntime(this); - try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {} - if (!this.gameAudioManager) { - this.gameAudioManager = new GameAudioManager(); - } - if (this._onScriptLog) this.gameRuntime.setOnLog(this._onScriptLog); - if (this._onScriptHud) this.gameRuntime.setOnHud(this._onScriptHud); - if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair); - } - this.gameRuntime.startSolo(sc); - return true; - } - - /** Остановить отладочный (solo) запуск. */ - stopSoloScript() { - if (this.gameRuntime && this.gameRuntime.isSolo?.()) { - this.gameRuntime.stop(); - // Если не в Play-режиме — освобождаем runtime - if (!this._isPlaying) { - this.gameRuntime = null; - } - } - } - - isSoloRunning() { - return !!this.gameRuntime?.isSolo?.(); - } - getSoloScriptId() { - return this.gameRuntime?.getSoloScriptId?.() || null; - } - - /** Получить все скрипты проекта. */ - getScripts() { return [...this._scripts]; } - - /** Заменить все скрипты (используется при load/edit). */ - setScripts(scripts) { - this._scripts = Array.isArray(scripts) ? scripts.slice() : []; - } - - /** Установить код одного скрипта по id. Если id нет — создать новый. */ - upsertScript(id, code, target = undefined) { - const i = this._scripts.findIndex(s => s.id === id); - if (i >= 0) { - this._scripts[i] = { - ...this._scripts[i], - code, - ...(target !== undefined ? { target } : {}), - }; - } else { - this._scripts.push({ - id: id || `script_${Date.now()}`, - code, - target: target !== undefined ? target : null, - }); - } - // Скрипты — часть сцены: фиксируем в истории, иначе undo откатит - // _scripts к снапшоту, снятому до создания скрипта, и скрипт пропадёт. - this.history?.markChange(); - if (this._onSceneChange) this._onSceneChange(); - } - - /** Удалить скрипт по id. */ - removeScript(id) { - this._scripts = this._scripts.filter(s => s.id !== id); - this.history?.markChange(); - if (this._onSceneChange) this._onSceneChange(); - } - - /** - * Зарегистрировать колбэк для уведомлений об изменении режима Play - * (вызывается когда player сам инициирует exit, например по Esc). - * KubikonEditor подписывается чтобы синхронизировать React-state. - */ - setOnPlayChange(cb) { - this._onPlayChange = cb; - } - - /** - * Колбэк «ESC в Play» для плеера: открыть меню-оверлей поверх живой игры - * БЕЗ выхода из Play. Если подписан — ESC не делает exitPlayMode (см. - * setOnExitRequest в enterPlayMode). В студии не подписывается → там ESC - * по-прежнему выходит из Play. - */ - setOnEscMenu(cb) { - this._onEscMenu = cb; - } - - /** - * Синхронизация состояния меню-оверлея из UI (React). Когда меню закрывают - * НЕ через ESC (кнопка «Продолжить»/крестик/клик по фону), UI обязан сообщить - * движку — иначе _playerMenuOpen рассинхронизируется и следующий ESC решит, - * что меню «открыто», и не откроет его. open=false также возвращает мышь в игру. - */ - setPlayerMenuOpen(open) { - const v = !!open; - if (this._playerMenuOpen === v) return; - this._playerMenuOpen = v; - if (!v) { - // меню закрыли из UI → вернуть управление камерой/мышью - try { this.player?.setUiCursorMode?.(false); } catch (e) { /* ignore */ } - } - } - - /** - * Колбэк изменения сцены (любая модификация блоков/моделей). - * Используется KubikonEditor для dirty-tracking → auto-save. - * Сами обработчики на blockManager/modelManager привязаны в init() — - * они дёргают и history.markChange() и this._onSceneChange. - */ - setOnSceneChange(cb) { - this._onSceneChange = cb; - } - - /** Колбэк изменения GUI-элементов (для перерисовки React-overlay). */ - setOnGuiChange(cb) { - this._onGuiChange = cb; - } - - /** Подключить API для пользовательских моделей (Kubikon3DService). - * Нужно дважды: setApi для самого UserModelManager (getUserModel) - * и сохранить _userModelsApi для incrementModelUses в _handlePlaceModel. */ - setUserModelsApi(api) { - this._userModelsApi = api; - if (this.userModelManager && api) { - this.userModelManager.setApi(api); - } - } - - /** Передать id текущего пользователя — для запросов к приватным моделям. */ - setCurrentUserId(userId) { - this._currentUserId = userId; - } - - /** Передать id текущего проекта — для game.save.* эндпоинтов (savegame API). - * Без этого скрипты не смогут сохранять прогресс. */ - setCurrentProjectId(projectId) { - this._currentProjectId = projectId; - } - - /** Колбэк изменения видимости стандартного HUD (HP-бар, hotbar, ...). - * Редактор/плеер подписываются и реактивно скрывают/показывают элементы. - * Скрипт зовёт game.hud.setVisible(false) → этот колбэк сработает. */ - setOnStdHudVisibilityChange(cb) { - this._onStdHudVisibilityChange = cb; - } - _setStdHudVisible(visible) { - this._stdHudVisible = !!visible; - try { this._onStdHudVisibilityChange?.(this._stdHudVisible); } catch (e) {} - } - - /** Скрыть/показать только хотбар (5 слотов инвентаря снизу). */ - _setHotbarVisible(visible) { - this._hotbarVisible = !!visible; - try { this._onHotbarVisibilityChange?.(this._hotbarVisible); } catch (e) {} - } - - /** Скрыть/показать только HP-индикатор (полоска жизней). */ - _setHpVisible(visible) { - this._hpVisible = !!visible; - try { this._onHpVisibilityChange?.(this._hpVisible); } catch (e) {} - } - - /** Колбэк смены cursor-режима (ui/game) скриптом через game.input.setCursorMode. - * Редактор подписан чтобы синхронизировать React-state uiCursorMode (для бейджа). */ - setOnCursorModeChange(cb) { - this._onCursorModeChange = cb; - } - - /** Пересобрать spatial-индекс физики для user-моделей. - * Вызывается из SelectionManager при изменении canCollide / anchored / - * position / rotation / scale. */ - _syncUserModelColliders() { - try { this.physics?.setSpatialDirty?.(); } catch (e) {} - } - - /** Инвалидировать пользовательскую модель после её редактирования. - * Сбрасывает кэш + пересоздаёт все инстансы этой модели в сцене с - * новой геометрией. Вызывается из KubikonEditor.jsx после закрытия - * редактора модели (когда editingUserModelId != null). */ - async refreshUserModel(userModelId) { - if (!this.userModelManager) return 0; - const rebuilt = await this.userModelManager.invalidateModel(userModelId, { - rebuild: true, - currentUserId: this._currentUserId || null, - }); - // Тени для свежесозданных мешей - if (rebuilt > 0) { - for (const inst of this.userModelManager.instances.values()) { - if (inst.userModelId === userModelId) { - for (const m of inst.meshes) { - try { this.addShadowCaster(m); } catch (e) {} - } - } - } - this._syncUserModelColliders(); - } - return rebuilt; - } - - /** Колбэк изменения инвентаря (для hot-bar React). */ - setOnInventoryChange(cb) { - this._onInventoryChange = cb; - } - - /** Колбэк изменения патронов оружия (для GUI). */ - setOnAmmoChange(cb) { - this._onAmmoChange = cb; - if (this.weapons) this.weapons.setOnAmmoChange(cb); - } - - /** Колбэк попадания пули (для логики урона зомби и др.). */ - setOnWeaponHit(cb) { - this._onWeaponHit = cb; - if (this.weapons) this.weapons.setOnHit(cb); - } - - /** Колбэк изменения HP игрока. */ - setOnPlayerHpChange(cb) { - this._onPlayerHpChange = cb; - if (this.player) this.player.setOnHpChange(cb); - } - - /** Колбэк смерти игрока. */ - setOnPlayerDeath(cb) { - this._onPlayerDeath = cb; - if (this.player) this.player.setOnDeath(cb); - } - - /** Колбэк Escape в редакторе (для возврата в инструмент «Выделить»). */ - setOnEditorEscape(cb) { - this._onEditorEscape = cb; - } - - getInventoryState() { - return this.inventory ? this.inventory.serialize() : { slots: [], activeIndex: 0 }; - } - - setActiveInventorySlot(index) { - this.inventory?.setActive(index); - // Если в Play — пересменяем оружие - if (this._isPlaying && this.weapons) { - const active = this.inventory?.getActive?.(); - if (active && active.kind === 'weapon') { - this.weapons.equip(active); - } else { - this.weapons.unequip(); - } - // Сообщаем мультиплееру о смене оружия — чтобы remote-клиенты - // увидели в руке нашей модели правильный GLB. - if (this._mpSync) { - const modelId = (active && active.kind === 'weapon') - ? (active.modelTypeId || '') - : ''; - try { this._mpSync.sendWeapon(modelId); } catch (e) {} - } - } - } - - addInventoryItem(item) { - return this.inventory?.add(item) ?? -1; - } - - /** API для UI: создать/изменить/удалить GUI-элемент. Делегирует в GuiManager. */ - createGuiElement(type, opts) { - return this.guiManager?.create(type, opts); - } - updateGuiElement(id, patch) { - this.guiManager?.update(id, patch); - } - removeGuiElement(id) { - // Если был выделен — снять выделение - if (this.selection?._selection?.type === 'gui' && this.selection._selection.id === id) { - this.selection.clearSelection?.(); - } - this.guiManager?.remove(id); - } - renameGuiElement(id, name) { - this.guiManager?.rename(id, name); - } - moveGuiElementZ(id, direction) { - this.guiManager?.moveZ(id, direction); - } - getGuiElements() { - return this.guiManager ? this.guiManager.getAll() : []; - } - - // ===== Задача 07: встроенный магазин скинов (React-оверлей) ===== - // Состояние держим тут; React-компонент SkinShopOverlay поллит getSkinShopState(). - _ensureSkinShopState() { - if (!this._skinShop) { - this._skinShop = { - open: false, - rev: 0, // ревизия — React видит изменение - data: { all: [], unlocked: [], current: null, coins: 0, manifestFull: [] }, - buyResult: null, // последний результат покупки {slug, ok, reason} - }; - } - return this._skinShop; - } - /** Снимок состояния магазина для React (поллинг через rAF). */ - getSkinShopState() { return this._skinShop || null; } - /** Открыть/закрыть магазин (из скрипта или клавиши B). */ - _openSkinShop() { - const s = this._ensureSkinShopState(); - // Отключён в проекте? (скрипт всё равно может открыть через API — - // shopVisible:false запрещает только клавишу B, см. toggleSkinShop). - s.open = true; s.rev++; - } - _closeSkinShop() { - const s = this._ensureSkinShopState(); - s.open = false; s.rev++; - } - toggleSkinShop() { - const s = this._ensureSkinShopState(); - if (s.open) { this._closeSkinShop(); return; } - // Клавиша B открывает магазин только если он включён в проекте. - if (this._skinsConfig && this._skinsConfig.shopVisible === false) return; - this._openSkinShop(); - } - /** Данные скинов от GameRuntime (манифест + unlocked + coins). */ - _setSkinShopData(data) { - const s = this._ensureSkinShopState(); - s.data = { ...s.data, ...data }; - s.rev++; - } - _onSkinBuyResult(res) { - const s = this._ensureSkinShopState(); - s.buyResult = { ...res, t: (this.scene?.getEngine?.()?.getFps?.() || 0) }; - s.rev++; - } - /** Намерение купить/надеть скин — шлём в runtime (он списывает рублики). */ - requestBuySkin(slug, price) { - const rt = this.gameRuntime; - if (!rt) return; - try { rt._handleCommand?.(null, 'player.buySkin', { slug, price: price || 0 }); } catch (e) {} - } - /** Данные из загруженных проектом кастомных .glb-скинов (data-URL по slug). */ - getAssetDataUrl(slug) { - try { - // Кастомные скины хранят dataUrl прямо в _skinsConfig.customGlbs. - const list = this._skinsConfig?.customGlbs || []; - const rec = list.find(g => g && g.slug === slug); - if (rec && rec.dataUrl) return rec.dataUrl; - } catch (e) {} - return null; - } - _onPlayerSkinChanged(slug) { - const s = this._ensureSkinShopState(); - if (s.data) { s.data.current = slug; s.rev++; } - } - - // ===== Библиотека пользовательских картинок (этап 3.6) ===== - - /** Список картинок проекта [{id, name, dataUrl}]. */ - getAssets() { - return this.assetManager ? this.assetManager.list() : []; - } - - /** Загрузить картинку из File. Возвращает Promise<{ok, id?, error?}>. */ - addAssetFromFile(file) { - if (!this.assetManager) return Promise.resolve({ ok: false, error: 'нет менеджера' }); - return this.assetManager.addFromFile(file).then((res) => { - if (res.ok && this._onSceneChange) this._onSceneChange(); - return res; - }); - } - - renameAsset(id, name) { - if (!this.assetManager) return; - this.assetManager.rename(id, name); - if (this._onSceneChange) this._onSceneChange(); - } - - /** - * Удалить картинку. Снимает её с примитивов/GUI, которые на неё ссылались — - * иначе остались бы «висячие» ссылки на несуществующий ассет. - */ - removeAsset(id) { - if (!this.assetManager) return; - this.assetManager.remove(id); - // Снять текстуру с примитивов, использовавших этот ассет. - if (this.primitiveManager) { - for (const data of this.primitiveManager.instances.values()) { - if (data.textureAsset === id) { - this.primitiveManager.updateInstance(data.id, { textureAsset: null }); - } - } - } - // Снять картинку с GUI-элементов image. - if (this.guiManager) { - for (const el of this.guiManager.getAll()) { - if (el.imageAsset === id) { - this.guiManager.update(el.id, { imageAsset: null }); - } - } - } - if (this._onSceneChange) this._onSceneChange(); - } - - // ===== Библиотека пользовательских звуков (Фаза 5.5) ===== - - /** Список звуков проекта [{id, name, dataUrl}]. */ - getSounds() { - return this.soundLibrary ? this.soundLibrary.list() : []; - } - - /** Загрузить звук из File. Возвращает Promise<{ok, id?, error?}>. */ - addSoundFromFile(file) { - if (!this.soundLibrary) return Promise.resolve({ ok: false, error: 'нет библиотеки' }); - return this.soundLibrary.addFromFile(file).then((res) => { - if (res.ok && this._onSceneChange) this._onSceneChange(); - return res; - }); - } - - renameSound(id, name) { - if (!this.soundLibrary) return; - this.soundLibrary.rename(id, name); - if (this._onSceneChange) this._onSceneChange(); - } - - removeSound(id) { - if (!this.soundLibrary) return; - this.soundLibrary.remove(id); - if (this._onSceneChange) this._onSceneChange(); - } - - // ===== Библиотека импортированных .glb-моделей (Фаза 5.8) ===== - - /** Список импортированных моделей [{id, name, dataUrl}]. */ - getGlbModels() { - return this.glbLibrary ? this.glbLibrary.list() : []; - } - - /** Загрузить .glb из File. Возвращает Promise<{ok, id?, error?}>. */ - addGlbFromFile(file) { - if (!this.glbLibrary) return Promise.resolve({ ok: false, error: 'нет библиотеки' }); - return this.glbLibrary.addFromFile(file).then((res) => { - if (res.ok && this._onSceneChange) this._onSceneChange(); - return res; - }); - } - - renameGlb(id, name) { - if (!this.glbLibrary) return; - this.glbLibrary.rename(id, name); - if (this._onSceneChange) this._onSceneChange(); - } - - removeGlb(id) { - if (!this.glbLibrary) return; - this.glbLibrary.remove(id); - if (this._onSceneChange) this._onSceneChange(); - } - - /** - * Колбэк после постановки нового объекта (блока/модели/примитива). - * Используется KubikonEditor чтобы переключить activeTool на 'select' - * и дать пользователю сразу таскать поставленный объект. - */ - setOnPostPlace(cb) { - this._onPostPlace = cb; - } - - /** - * Сохранить позиции всех unanchored объектов перед стартом физики. - * При exitPlayMode они возвращаются на эти позиции. - */ - _snapshotDynamicObjects() { - this._dynamicSnapshot = []; - if (this.blockManager) { - // Запоминаем позиции unanchored блоков (mesh-position). - // Сами блоки ОСТАЮТСЯ в blockManager.blocks Map, иначе вся остальная - // логика (сериализация, удаление, выделение) сломается. - // PhysicsAABB при Play фильтрует hasBlock через metadata.anchored - // и не считает unanchored клетку статичным препятствием. - for (const mesh of this.blockManager.blocks.values()) { - if (mesh.metadata?.anchored === false) { - this._dynamicSnapshot.push({ - kind: 'block', - mesh, - x: mesh.position.x, y: mesh.position.y, z: mesh.position.z, - rotX: mesh.rotation?.x || 0, - rotY: mesh.rotation?.y || 0, - rotZ: mesh.rotation?.z || 0, - }); - } - } - } - if (this.primitiveManager) { - for (const data of this.primitiveManager.instances.values()) { - if (data.anchored === false) { - this._dynamicSnapshot.push({ - kind: 'primitive', data, - x: data.x, y: data.y, z: data.z, - rotX: data.mesh?.rotation?.x || 0, - rotY: data.mesh?.rotation?.y || 0, - rotZ: data.mesh?.rotation?.z || 0, - }); - } - } - } - if (this.modelManager) { - for (const data of this.modelManager.instances.values()) { - if (data.anchored === false) { - this._dynamicSnapshot.push({ - kind: 'model', data, - x: data.x, y: data.y, z: data.z, - rotX: data.rootMesh?.rotation?.x || 0, - rotY: data.rootMesh?.rotation?.y || 0, - rotZ: data.rootMesh?.rotation?.z || 0, - }); - } - } - } - } - - _restoreDynamicObjects() { - if (!this._dynamicSnapshot) return; - for (const snap of this._dynamicSnapshot) { - if (snap.kind === 'block' && snap.mesh) { - snap.mesh.position.x = snap.x; - snap.mesh.position.y = snap.y; - snap.mesh.position.z = snap.z; - if (snap.mesh.rotation) { - snap.mesh.rotation.x = snap.rotX || 0; - snap.mesh.rotation.y = snap.rotY || 0; - snap.mesh.rotation.z = snap.rotZ || 0; - } - snap.mesh.setEnabled(true); - } else if (snap.kind === 'primitive' && snap.data) { - snap.data.x = snap.x; snap.data.y = snap.y; snap.data.z = snap.z; - if (snap.data.mesh) { - snap.data.mesh.position.set(snap.x, snap.y, snap.z); - if (snap.data.mesh.rotation) { - snap.data.mesh.rotation.x = snap.rotX || 0; - snap.data.mesh.rotation.y = snap.rotY || 0; - snap.data.mesh.rotation.z = snap.rotZ || 0; - } - snap.data.mesh.setEnabled(snap.data.visible !== false); - } - } else if (snap.kind === 'model' && snap.data) { - snap.data.x = snap.x; snap.data.y = snap.y; snap.data.z = snap.z; - if (snap.data.rootMesh) { - snap.data.rootMesh.position.set(snap.x, snap.y, snap.z); - if (snap.data.rootMesh.rotation) { - snap.data.rootMesh.rotation.x = snap.rotX || 0; - snap.data.rootMesh.rotation.y = snap.rotY || 0; - snap.data.rootMesh.rotation.z = snap.rotZ || 0; - } - snap.data.rootMesh.setEnabled(true); - } - } - } - this._dynamicSnapshot = null; - } - - /** - * Полный снимок сцены перед Play — примитивы и модели целиком. - * При exitPlayMode сцена восстанавливается ровно к этому состоянию: - * вернутся удалённые скриптом объекты, откатятся цвет/видимость/ - * коллизия/поворот, исчезнут заспавненные скриптом объекты. - * - * Зачем: скрипты игр меняют сцену деструктивно (game.self.delete, - * setColor, tween rotationY и т.д.). Без полного отката после - * Stop→Play сцена остаётся «использованной» — собранные монетки - * не появляются, открытая дверь остаётся открытой. Это как Stop - * в Roblox Studio: сцена возвращается к авторскому виду. - * - * Блоки СЮДА НЕ входят — их скрипты практически не меняют, а полная - * пересборка тысяч блоков дорогая. Падающие unanchored-блоки и так - * откатываются через _restoreDynamicObjects (позиции). - */ - _snapshotFullScene() { - this._fullSceneSnapshot = null; - try { - const snap = {}; - if (this.primitiveManager) { - snap.primitives = this.primitiveManager.serialize(); - } - if (this.modelManager) { - snap.models = this.modelManager.serialize(); - } - this._fullSceneSnapshot = snap; - } catch (e) { - console.warn('[BabylonScene] _snapshotFullScene не удался:', e); - this._fullSceneSnapshot = null; - } - } - - /** - * Восстановить сцену из полного снимка после Play. - * Пересоздаёт примитивы и модели точь-в-точь (id сохраняются — - * addInstance принимает opts.id, поэтому скрипты на объектах после - * рестарта снова найдут свои target.id). - */ - _restoreFullScene() { - if (!this._fullSceneSnapshot) return; - const snap = this._fullSceneSnapshot; - this._fullSceneSnapshot = null; - try { - // Сбрасываем выделение — loadFromArray диспозит старые mesh, - // selection не должен держать мёртвую ссылку. - try { this.selection?.clear?.(); } catch (e) {} - if (this.primitiveManager && Array.isArray(snap.primitives)) { - this.primitiveManager.loadFromArray(snap.primitives); - } - if (this.modelManager && Array.isArray(snap.models)) { - // loadFromArray у моделей async (модели грузятся с диска) — - // не ждём, восстановление догрузится в фоне. - Promise.resolve(this.modelManager.loadFromArray(snap.models)) - .catch((e) => console.warn('[BabylonScene] откат моделей:', e)); - } - } catch (e) { - console.warn('[BabylonScene] _restoreFullScene не удался:', e); - } - } - - /** Поменять anchored у выделенного объекта. */ - setSelectedAnchored(anchored) { - this.selection?.setSelectedAnchored(anchored); - } - - /** === Окружение / Время суток / Аудио / Вода === */ - setEnvironmentPreset(preset) { this.environment?.setPreset(preset); } - setTimeOfDay(hour) { this.environment?.setTimeOfDay(hour); } - setCycleDuration(dayMin, nightMin) { this.environment?.setCycleDuration(dayMin, nightMin); } - setFog(enabled, color, density) { this.environment?.setFog(enabled, color, density); } - getEnvironmentState() { return this.environment?.serialize() || null; } - - setAmbientAudio(opts) { this.audioManager?.setAmbient(opts); } - setMusicAudio(opts) { this.audioManager?.setMusic(opts); } - getAudioState() { return this.audioManager?.serialize() || null; } - - /** Доступные пресеты амбиента/музыки для UI. */ - getAudioPresets() { - return { ambient: AMBIENT_PRESETS || [], music: MUSIC_PRESETS || [] }; - } - - /** Доступные модели игрока (категория «Персонажи»). */ - getPlayerOptions() { - // Импорт MODEL_TYPES сложен из engine, поэтому берём через _playerOptionsCache - return this._playerOptionsCache || []; - } - setPlayerOptions(list) { this._playerOptionsCache = list; } - - /** Обновить пресет амбиента/музыки и обновить selection если открыт. */ - setSoundProps(patch) { - if (!patch) return; - if (patch.ambientId !== undefined) { - this.audioManager?.setAmbient({ preset: patch.ambientId }); - } - if (patch.musicId !== undefined) { - this.audioManager?.setMusic({ preset: patch.musicId }); - } - if (this.selection?._selection?.type === 'sound') { - this.selection.selectSound(); - } - } - - /** Обновить тип персонажа / силу прыжка / прицел. */ - setPlayerProps(patch) { - if (!patch) return; - if (patch.playerModelType) { - this.setPlayerModelType(patch.playerModelType); - } - if (typeof patch.jumpPower === 'number' && patch.jumpPower > 0) { - this.setPlayerJumpPower(patch.jumpPower); - } - if (typeof patch.crosshair === 'string') { - this.setCrosshair(patch.crosshair); - } - if (this.selection?._selection?.type === 'player') { - this.selection.selectPlayer(); - } - } - - /** Поменять mass у выделенного объекта. */ - setSelectedMass(mass) { - this.selection?.setSelectedMass(mass); - } - - /** Поменять свойства модели (canCollide / visible). */ - setSelectedModelProps(patch) { - if (!this.selection) return; - const sel = this.selection.getSelection(); - if (sel?.type === 'userModel') { - this.selection.setSelectedUserModelProps(patch); - return; - } - this.selection.setSelectedModelProps(patch); - } - - /** Поменять свойства блока (canCollide / visible). */ - setSelectedBlockProps(patch) { - this.selection?.setSelectedBlockProps(patch); - } - - /** === Папки/группы === */ - createFolder(name = 'Новая папка', parentId = null) { - return this.folderManager?.createFolder(name, parentId) ?? null; - } - renameFolder(id, name) { this.folderManager?.renameFolder(id, name); } - - /** Переименовать скрипт по id. Имя сохраняется в поле name. */ - renameScript(id, name) { - const i = this._scripts.findIndex(s => s.id === id); - if (i < 0) return false; - this._scripts[i] = { ...this._scripts[i], name: String(name || '').trim() || null }; - this.history?.markChange(); - if (this._onSceneChange) this._onSceneChange(); - return true; - } - - /** Переименовать инстанс модели. */ - renameModel(instanceId, name) { - const data = this.modelManager?.instances?.get(instanceId); - if (!data) return false; - data.name = String(name || '').trim() || null; - if (this._onSceneChange) this._onSceneChange(); - this.modelManager?._notifyChange?.(); - return true; - } - - /** Переименовать примитив. */ - renamePrimitive(id, name) { - const data = this.primitiveManager?.instances?.get(id); - if (!data) return false; - data.name = String(name || '').trim() || null; - if (this._onSceneChange) this._onSceneChange(); - this.primitiveManager?._notifyChange?.(); - return true; - } - removeFolder(id, deleteContent = false) { this.folderManager?.removeFolder(id, deleteContent); } - setFolderVisible(id, visible) { this.folderManager?.setVisible(id, visible); } - assignToFolder(kind, ref, folderId) { this.folderManager?.assignToFolder(kind, ref, folderId); } - /** Положить выделенное в указанную папку (или null = в корень). */ - assignSelectionToFolder(folderId) { - const sel = this.selection?.getSelection(); - if (!sel) return; - if (sel.type === 'block') { - this.folderManager?.assignToFolder('block', { x: sel.gridX, y: sel.gridY, z: sel.gridZ }, folderId); - } else if (sel.type === 'model') { - this.folderManager?.assignToFolder('model', sel.instanceId, folderId); - } else if (sel.type === 'primitive') { - this.folderManager?.assignToFolder('primitive', sel.id, folderId); - } - } - - /** Undo. */ - undo() { - return this.history?.undo(); - } - - /** Redo. */ - redo() { - return this.history?.redo(); - } - - /** Можно ли откатиться. */ - canUndo() { - return !!this.history?.canUndo(); - } - - /** Можно ли вернуть. */ - canRedo() { - return !!this.history?.canRedo(); - } - - /** - * Захватить превью-скриншот сцены как data URL (PNG, base64). - * Используется для иконки проекта в «Мои игры». - * size — размер квадратного превью в пикселях (по умолчанию 256). - */ - captureThumbnail(size = 256) { - if (!this.canvas) return null; - try { - // Простейший способ — взять текущий canvas-buffer и масштабировать его - // в новый offscreen canvas размера size×size. - const out = document.createElement('canvas'); - out.width = size; - out.height = size; - const ctx = out.getContext('2d'); - // Чёрная заливка на случай прозрачности - ctx.fillStyle = '#1a1410'; - ctx.fillRect(0, 0, size, size); - // Принудительный рендер чтобы backbuffer был свежим - if (this.scene) this.scene.render(); - // Сохраняем сохраняя пропорции — рисуем по короткой стороне - const sw = this.canvas.width, sh = this.canvas.height; - const minSide = Math.min(sw, sh); - const sx = (sw - minSide) / 2; - const sy = (sh - minSide) / 2; - ctx.drawImage(this.canvas, sx, sy, minSide, minSide, 0, 0, size, size); - return out.toDataURL('image/jpeg', 0.7); // JPEG-70% — ~10-30 КБ - } catch (e) { - // eslint-disable-next-line no-console - console.error('[BabylonScene] thumbnail error:', e); - return null; - } - } - - // === СОХРАНЕНИЕ И ЗАГРУЗКА =========================================== - - /** - * Сериализовать сцену в JSON-объект для сохранения в БД. - * Включает: блоки, модели, точку спавна, позицию редактор-камеры. - */ - /** - * Подготовить мини-карту для уже загруженного проекта (когда нет - * GeneratorParams). Считаем bbox реальных voxel'ов и сохраняем в - * window.__lastGenSize, чтобы MinimapOverlay масштабировался правильно. - * MinimapOverlay должна сама уметь рендерить real-data fallback. - */ - _setupMinimapForLoadedProject() { - if (!this.terrainManager || !this.terrainManager.voxels) return; - const voxels = this.terrainManager.voxels; - if (voxels.size === 0) return; - // Считаем bbox по X/Z. Берём max(|x|, |z|) как halfSize. - let maxAbs = 0; - for (const key of voxels.keys()) { - const lastComma = key.lastIndexOf(','); - const midComma = key.lastIndexOf(',', lastComma - 1); - const x = parseInt(key.slice(0, midComma), 10); - const z = parseInt(key.slice(lastComma + 1), 10); - const ax = Math.abs(x); - const az = Math.abs(z); - if (ax > maxAbs) maxAbs = ax; - if (az > maxAbs) maxAbs = az; - } - // maxAbs — в voxel-units. size для мини-карты = halfSize × 1.1 (запас). - const size = Math.ceil(maxAbs * 1.1); - window.__lastGenSize = size; - // Если ничего не было сгенерировано — НЕ ставим __lastGenParams. - // MinimapOverlay использует fallback на real-data top-down рендер. - console.log(`[BabylonScene] minimap configured for loaded project: half-size=${size} voxel-units (${(size * 2 * 0.25).toFixed(0)}м)`); - } - - /** - * Подготовить мини-карту для гладкого ландшафта (RobloxTerrain). - * - * Воксельная миникарта (_setupMinimapForLoadedProject) читает - * terrainManager.voxels — для smooth terrain их нет. Здесь публикуем - * ссылку на density-grid в window.__robloxMinimapGrid, а MinimapOverlay - * сам строит top-down heightmap из неё. - * - * Ставим _terrainStreamingEnabled=true чтобы MinimapOverlay стал visible - * (он показывается по этому флагу), даже если у гладкого ландшафта - * нет настоящего streaming-режима. - */ - _setupMinimapForRobloxTerrain() { - const grid = this._robloxTerrain?.grid; - if (!grid) return; - // CELL_SIZE=4м. Полная ширина карты в метрах. - const worldM = grid.size.x * 4; - // MinimapOverlay масштаб: getWorldViewM() = size*2*0.25. - // Чтобы worldViewM == worldM → size = worldM*2. - window.__lastGenSize = worldM * 2; - window.__lastGenParams = null; // не procedural-режим - window.__robloxMinimapGrid = grid; // density-grid для real-data рендера - this._terrainStreamingEnabled = true; // делает MinimapOverlay видимым - // ВАЖНО: _worldHalf по умолчанию 40 — на больших гладких картах это - // зажимало зомби/мобов в крошечный квадрат ±40 (они телепортировались - // к центру и проваливались). Подгоняем под реальный размер карты. - const half = Math.ceil(worldM / 2); - if (this._worldHalf < half) { - this._worldHalf = half; - // Синхронизируем physics.floorHalf — иначе игрок проваливается - // сквозь baseplate за пределами центральных 80×80. - if (this.physics) this.physics.floorHalf = half; - console.log(`[BabylonScene] _worldHalf -> ${half} (под размер гладкого ландшафта)`); - } - console.log(`[BabylonScene] minimap configured for RobloxTerrain: ${worldM}м (grid ${grid.size.x}×${grid.size.y}×${grid.size.z})`); - } - - /** - * Снять ТОЧНУЮ карту высот гладкого ландшафта (RobloxTerrain). - * - * Зачем: density-grid квантует высоту по 4м, а Surface Nets рендерит - * сглаженную поверхность между ячейками — реальная видимая высота - * отличается от грубой оценки по grid. Чтобы корректно ставить - * объекты/блоки на землю, нужна высота РЕАЛЬНОГО меша. - * - * Метод raycast'ит сверху-вниз по мешам RobloxTerrain в сетке точек - * (шаг step метров) и возвращает объект с картой высот. Используется - * билд-скриптами игр для точного размещения. - * - * @param {number} step шаг сетки в метрах (по умолчанию 2) - * @returns {object|null} { format, origin, worldSize, step, cols, rows, heights[] } - * heights — плоский массив (rows × cols), значение = Y поверхности или null. - */ - exportRobloxHeightmap(step = 2) { - const rt = this._robloxTerrain; - if (!rt || !rt.grid) { - console.warn('[exportRobloxHeightmap] нет гладкого ландшафта'); - return null; - } - const grid = rt.grid; - const CS = 4; // CELL_SIZE - // Мировые границы карты по grid. - const minWX = grid.origin.x * CS; - const minWZ = grid.origin.z * CS; - const worldX = grid.size.x * CS; - const worldZ = grid.size.z * CS; - const cols = Math.ceil(worldX / step) + 1; - const rows = Math.ceil(worldZ / step) + 1; - - // Предикат: только меши гладкого ландшафта. - const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain); - const heights = new Array(cols * rows).fill(null); - let hit = 0; - const t0 = performance.now(); - for (let r = 0; r < rows; r++) { - const wz = minWZ + r * step; - for (let c = 0; c < cols; c++) { - const wx = minWX + c * step; - const ray = new Ray(new Vector3(wx, 5000, wz), new Vector3(0, -1, 0), 10000); - const pick = this.scene.pickWithRay(ray, pickPred); - if (pick && pick.hit && pick.pickedPoint) { - heights[r * cols + c] = +pick.pickedPoint.y.toFixed(3); - hit++; - } - } - } - const dt = (performance.now() - t0).toFixed(0); - console.log(`[exportRobloxHeightmap] ${cols}×${rows} точек, ${hit} попаданий, ${dt}мс`); - - return { - format: 'roblox-heightmap-v1', - origin: { x: minWX, z: minWZ }, - worldSize: { x: worldX, z: worldZ }, - gridOrigin: { ...grid.origin }, - gridSize: { ...grid.size }, - step, - cols, - rows, - heights, - }; - } - - serialize() { - // Принадлежность объектов папкам — серилизуется в их собственных - // данных (folderId), а сами папки в отдельном массиве. - const blocksWithFolders = this.blockManager ? this.blockManager.serialize() : []; - // BlockManager.serialize не знает про folderId — добавляем его поверх. - if (this.blockManager) { - for (const item of blocksWithFolders) { - const mesh = this.blockManager.blocks.get(`${item.x},${item.y},${item.z}`); - item.folderId = mesh?.metadata?.folderId ?? null; - } - } - const modelsWithFolders = this.modelManager ? this.modelManager.serialize() : []; - if (this.modelManager) { - // Дописываем instanceId + folderId поверх стандартной сериализации - // (которая уже включает type/x/y/z/rotationY/anchored/canCollide/visible/mass) - const live = Array.from(this.modelManager.instances.values()); - for (let i = 0; i < modelsWithFolders.length && i < live.length; i++) { - modelsWithFolders[i].instanceId = live[i].instanceId; - modelsWithFolders[i].folderId = live[i].folderId ?? null; - } - } - const primitivesWithFolders = this.primitiveManager ? this.primitiveManager.getAll() : []; - if (this.primitiveManager) { - for (let i = 0; i < primitivesWithFolders.length; i++) { - const id = primitivesWithFolders[i].id; - const data = this.primitiveManager.instances.get(id); - primitivesWithFolders[i].folderId = data?.folderId ?? null; - } - } - // Terrain: RLE-формат для больших карт (×25 меньше чем legacy array). - // На карте 250м: ~1.5МБ вместо ~38МБ. - let terrainData; - if (this.terrainManager) { - const voxelCount = this.terrainManager.voxels?.size ?? 0; - if (voxelCount > 5000 && typeof this.terrainManager.serializeRLE === 'function') { - terrainData = this.terrainManager.serializeRLE(); - } else { - terrainData = this.terrainManager.serialize(); - } - } else { - terrainData = []; - } - // Roblox smooth terrain — отдельная подсистема, сериализуется параллельно - let robloxTerrainData = null; - if (this._robloxTerrain && this._robloxTerrain.grid) { - try { - robloxTerrainData = this._robloxTerrain.serialize(); - // Декорации сохраняем двумя способами: - // - decoParams (seed + density) — для процедурной генерации - // - decoInstances (полные матрицы) — для ручных правок plant-кистью - // При load — приоритет у decoInstances если они есть. - if (this._smoothDecoParams) { - robloxTerrainData.decoParams = this._smoothDecoParams; - } - if (this._smoothDecoManager) { - const stats = this._smoothDecoManager.getStats?.(); - if (stats && stats.total > 0) { - try { - robloxTerrainData.decoInstances = this._smoothDecoManager.serialize(); - } catch (e) { - console.warn('smoothDeco serialize failed:', e); - } - } - } - } catch (e) { console.warn('robloxTerrain serialize failed:', e); } - } - - return { - version: 1, - scene: { - blocks: blocksWithFolders, - models: modelsWithFolders, - primitives: primitivesWithFolders, - // Этап 5: пользовательские воксельные модели (созданные через - // ModelEditorScreen). Каждая запись: { type:'user:42', x,y,z, ry }. - userModels: this.userModelManager ? this.userModelManager.serialize() : [], - terrain: terrainData, - robloxTerrain: robloxTerrainData, - decorations: this.decoManager ? this.decoManager.serialize() : [], - folders: this.folderManager ? this.folderManager.serialize() : [], - gui: this.guiManager ? this.guiManager.serialize() : [], - inventory: this.inventory ? this.inventory.serialize() : null, - 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: чем больше карта, тем меньше radius. - // Поднял пороги (2026-05-27): на средних картах (1-3M voxels) - // streaming-radius 28-32м слишком мал для замкнутых объектов - // типа вулкана — игрок видит «полупрозрачные» стены, потому - // что дальние регионы стенки не материализованы. - let radius = 80; - if (finalVoxelCount > 5_000_000) radius = 40; - else if (finalVoxelCount > 3_000_000) radius = 55; - else if (finalVoxelCount > 1_000_000) radius = 70; - this._terrainStreamingRadius = radius; - this._terrainStreamingLastUpdate = 0; - // Автотуман для скрытия границы streaming. Без него видно - // резкий обрыв террейна на радиусе. - // - // Density подбираем по editor-radius: чем больше radius, - // тем дальше начало тумана. Для radius=72м: 0.005 — туман - // едва заметен в ближнем плане, но прячет обрыв на 70м. - try { - if (this.scene) { - const camY = this.camera?.position.y || 0; - const heightBonus = Math.max(0, Math.min(20, camY * 0.3)); - const effectiveRadius = Math.min(60, this._terrainStreamingRadius * 1.3 + heightBonus); - // Эмпирически: fogDensity ≈ 0.5/radius работает. - // На radius=72м → 0.007 (туман на 70-90м) - // На radius=40м → 0.0125 (туман на 40-60м, как раньше) - const density = Math.max(0.004, Math.min(0.014, 0.5 / effectiveRadius)); - this.scene.fogMode = 2; // FOGMODE_EXP - this.scene.fogColor = new Color3(0.55, 0.7, 0.85); // светло-голубой - this.scene.fogDensity = density; - } - } catch (e) {} - // Сразу первый pass с editor-radius формулой (см. render loop). - if (this.camera) { - const camY = this.camera.position.y || 0; - const heightBonus = Math.max(0, Math.min(20, camY * 0.3)); - const editorRadius = Math.min(60, this._terrainStreamingRadius * 1.3 + heightBonus); - const r = this.terrainManager.updateStreaming( - this.camera.position.x, this.camera.position.z, - editorRadius, - ); - console.log(`[BabylonScene] auto-streaming ON: ${r.enabled}/${r.total} regions visible (editor radius=${editorRadius.toFixed(0)}m, play radius=${this._terrainStreamingRadius}m)`); - } - this._setupMinimapForLoadedProject(); - } else { - console.log(`[BabylonScene] streaming NOT enabled (regionCount=0). Карта маленькая или region-split не сработал.`); - } - } - - // === Загрузка Roblox smooth terrain (параллельная подсистема) === - // Если в проекте есть robloxTerrain — создаём менеджер и загружаем grid. - const rts = state.scene.robloxTerrain; - if (rts && rts.format === 'robloxterrain-v1') { - try { - setProgress(90, 'Загрузка гладкого ландшафта…'); - if (!this._robloxTerrain) { - this._robloxTerrain = new RobloxTerrain(this.scene); - if (this.physics?.setRobloxTerrain) { - this.physics.setRobloxTerrain(this._robloxTerrain); - } - } - this._robloxTerrain.loadFromState(rts); - // Сразу материализуем chunks вокруг камеры - const camX = this.camera?.position.x || 0; - const camZ = this.camera?.position.z || 0; - const r = this._robloxTerrain.updateStreaming(camX, camZ, 99999, { maxBuild: 9999 }); - const stats = this._robloxTerrain.getStats(); - console.log(`[BabylonScene] LOAD robloxTerrain: ${r.built} chunks, ${stats.triangles} triangles`); - // Включаем мини-карту для гладкого ландшафта — MinimapOverlay - // показывается по флагу _terrainStreamingEnabled, а heightmap - // строит из density-grid (window.__robloxMinimapGrid). - this._setupMinimapForRobloxTerrain(); - // Если есть RobloxTerrain — отключаем baseplate-floor чтобы - // не создавал ложных коллизий под smooth-ландшафтом. - // НО только если рельеф большой (≥500 cells) — иначе baseplate - // нужен для визуального ориентирования и для plant-decos - // которые ставятся на y=0. - if (stats.solidCells >= 500) { - try { this.setFloorEnabled(false); } catch (e) {} - } - // === Загрузка smooth-decorations === - // Приоритет 1: decoInstances (точные матрицы, ручные правки plant-кистью) - // Приоритет 2: decoParams (seed-based процедурная генерация) - if (rts.decoInstances && rts.decoInstances.items?.length > 0) { - try { - if (!this._smoothDecoManager) { - this._smoothDecoManager = new SmoothDecoManager(this.scene); - } - const r = await this._smoothDecoManager.loadFromState(rts.decoInstances); - const total = r?.total ?? 0; - const tc = r?.treeColliders ?? []; - console.log(`[BabylonScene] LOAD smooth decorations (instances): ${total} (${tc.length} tree colliders)`); - if (this.physics?.setSmoothDecoTrees) { - this.physics.setSmoothDecoTrees(tc); - } - } catch (err) { - console.error('[BabylonScene] smooth decoInstances load failed:', err); - } - } else if (rts.decoParams) { - try { - const dp = rts.decoParams; - this._smoothDecoParams = dp; - if (!this._smoothDecoManager) { - this._smoothDecoManager = new SmoothDecoManager(this.scene); - } - await this._smoothDecoManager.loadAll(); - const decoGen = new WorldGenerator(dp.genParams); - const sampleSurfaceY = (x, z) => { - if (!this.physics?._sampleRobloxSurface) return null; - return this.physics._sampleRobloxSurface(x, z); - }; - const sampleBiomeId = (x, z) => { - const b = decoGen.sampleBiome(x * 4, z * 4); - return b?.id; - }; - const r2 = this._smoothDecoManager.placeDecorations({ - sampleSurfaceY, sampleBiomeId, bbox: dp.bbox, - densityFlowers: dp.flowersDensity, - densityGrass: dp.grassDensity, - densityTrees: dp.treesDensity ?? 0, - seed: dp.seed, - }); - console.log(`[BabylonScene] LOAD smooth decorations (params): ${r2.total} instances`); - if (this.physics?.setSmoothDecoTrees && r2.treeColliders) { - this.physics.setSmoothDecoTrees(r2.treeColliders); - } - } catch (err) { - console.error('[BabylonScene] smooth decorations load failed:', err); - } - } - } catch (err) { - console.error('[BabylonScene] robloxTerrain load failed:', err); - } - } - - setProgress(95, 'Размещение декораций…'); - // Этап 6: загрузка decorations (цветы/грибы/трава мини-вокселями). - if (this.decoManager && Array.isArray(state.scene.decorations)) { - this.decoManager.loadFromArray(state.scene.decorations); - // Этап D LOD: первый pass streaming для деко. - // maxBuild=2 — строим только 2 ближайших chunk сразу (карта появляется - // мгновенно). Остальные доступные подгружаются по 2-4 за тик - // (200мс), весь видимый набор за 1-2 секунды. UI не блокируется. - if (this.camera && this.decoManager.updateStreaming) { - const decoRadius = Math.max(18, (this._terrainStreamingRadius || 40) * 0.35); - const r = this.decoManager.updateStreaming( - this.camera.position.x, this.camera.position.z, decoRadius, - { maxBuild: 2 }, - ); - console.log(`[BabylonScene] deco streaming ON: ${r.visible}/${r.total} chunks visible (radius=${decoRadius.toFixed(0)}m)`); - } - } - - // Модели — асинхронно (GLB подгружаются) - if (this.modelManager && Array.isArray(state.scene.models)) { - await this.modelManager.loadFromArray(state.scene.models); - } - - // Этап 5: пользовательские воксельные модели — асинхронно - // (model_data грузится через API). Каждый item: {type:'user:42', x,y,z,ry,scale,canCollide,...}. - if (this.userModelManager && Array.isArray(state.scene.userModels) - && state.scene.userModels.length > 0) { - const loaded = await this.userModelManager.loadFromArray( - state.scene.userModels, - { currentUserId: this._currentUserId }, - ); - console.log(`[BabylonScene] user models loaded: ${loaded}/${state.scene.userModels.length}`); - // Регистрируем коллайдеры в физике - this._syncUserModelColliders(); - } - - // === Тип модели персонажа — РЕШАЕМ ДО предзагрузки/плеера === - // ВАЖНО: должно стоять ВЫШЕ _loadPrototype и до enterPlayMode, иначе - // PlayerController прочитает старый _playerModelType (баг: пончик 2046 - // не ставился — skins.default применялся ниже, после предзагрузки). - // Миграция: старые проекты сохраняли Kenney-модель ('character-a..g'); - // форсим R15 bacon-hair. Явно выбранные 'skin_*' не трогаем. - if (state.scene.playerModelType) { - const pmt = state.scene.playerModelType; - this._playerModelType = pmt.startsWith('character-') ? 'skin_bacon-hair' : 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; - } - - // ОПТИМИЗАЦИЯ: предзагружаем модель ИГРОКА (character) тоже — - // PlayerController.start() её ждёт, но если предзагрузить сейчас, - // на enterPlayMode она будет в кэше Babylon и стартует мгновенно. - // ВАЖНО: R15-скины ('skin_*') — отдельная система (characters// - // body.glb + манифест), ModelManager их не знает. Их грузит сам - // PlayerController через _loadSkinManifest — здесь пропускаем, - // иначе ModelManager пишет в консоль 'Unknown model type'. - try { - const playerModelType = this._playerModelType || 'character-a'; - if (!String(playerModelType).startsWith('skin_')) { - await this.modelManager?._loadPrototype?.(playerModelType); - } - } catch (e) { /* ignore */ } - - // Примитивы — синхронно - if (this.primitiveManager && Array.isArray(state.scene.primitives)) { - this.primitiveManager.loadFromArray(state.scene.primitives); - } - - // Папки + восстановление folderId на всех объектах - if (this.folderManager) { - this.folderManager.loadFromArray(state.scene.folders || []); - } - // GUI-элементы - if (this.guiManager) { - this.guiManager.loadFromArray(state.scene.gui || []); - } - // Инвентарь - if (this.inventory) { - this.inventory.loadFromArray(state.scene.inventory || null); - } - // Простановка folderId на блоках (loadFromArray BlockManager не знает про это поле) - if (this.blockManager && Array.isArray(state.scene.blocks)) { - for (const b of state.scene.blocks) { - if (b.folderId == null) continue; - const mesh = this.blockManager.blocks.get(`${b.x},${b.y},${b.z}`); - if (mesh && mesh.metadata) mesh.metadata.folderId = b.folderId; - } - } - if (this.modelManager && Array.isArray(state.scene.models)) { - // ModelManager.loadFromArray генерирует новые instanceId, - // поэтому folderId восстанавливаем по индексу (порядку). - const arr = state.scene.models; - const liveIds = Array.from(this.modelManager.instances.keys()); - for (let i = 0; i < arr.length && i < liveIds.length; i++) { - if (arr[i].folderId == null) continue; - const data = this.modelManager.instances.get(liveIds[i]); - if (data) data.folderId = arr[i].folderId; - } - } - if (this.primitiveManager && Array.isArray(state.scene.primitives)) { - // primitiveManager после loadFromArray генерирует новые id, поэтому - // восстановим folderId по индексу (порядку) — он совпадает. - const arr = state.scene.primitives; - const liveIds = Array.from(this.primitiveManager.instances.keys()); - for (let i = 0; i < arr.length && i < liveIds.length; i++) { - if (arr[i].folderId == null) continue; - const data = this.primitiveManager.instances.get(liveIds[i]); - if (data) data.folderId = arr[i].folderId; - } - } - // После расстановки folderId — применим эффективную видимость папок - if (this.folderManager) { - for (const f of this.folderManager.getAll()) { - this.folderManager._applyVisibility(f.id, this.folderManager._effectiveVisible(f.id)); - } - } - - // Зарегистрировать все объекты как shadow casters - this.refreshAllShadows(); - - // Точка спавна - if (state.scene.spawnPoint) { - this._spawnPoint = { ...state.scene.spawnPoint }; - this._updateSpawnMarker(); - } - // === Авто-fix спавна для smooth terrain === - // Если есть RobloxTerrain и spawnPoint оказался НИЖЕ поверхности — - // поднимаем его, чтобы игрок не провалился в момент нажатия "Запустить". - // Иначе raycast pickWithRay возвращает hit В ОБОИХ направлениях (mesh - // обволакивает AABB), физика застревает в UNSTUCK-цикле и игрок падает - // в бездну. - try { - if (this._robloxTerrain - && (this._robloxTerrain.getStats?.().solidCells ?? 0) > 0 - && this.physics?._sampleRobloxSurface) { - const surfaceY = this.physics._sampleRobloxSurface(this._spawnPoint.x, this._spawnPoint.z); - if (surfaceY !== null && this._spawnPoint.y < surfaceY + 1) { - const newY = surfaceY + 2; - console.log(`[BabylonScene] spawn auto-lifted: y ${this._spawnPoint.y.toFixed(2)} → ${newY.toFixed(2)} (surface=${surfaceY.toFixed(2)})`); - this._spawnPoint.y = newY; - this._updateSpawnMarker(); - } else if (surfaceY === null) { - console.warn('[BabylonScene] spawn auto-lift: no surface found under spawn'); - } - } - } catch (e) { console.warn('[BabylonScene] spawn auto-lift failed:', e); } - // (Тип модели персонажа и skins решены выше — до предзагрузки модели.) - // Пользовательские скрипты - if (Array.isArray(state.scene.scripts)) { - this._scripts = state.scene.scripts - .filter(s => s && typeof s.code === 'string') - .map(s => ({ - id: s.id || `script_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, - code: s.code, - target: s.target || null, - name: s.name || null, - })); - } - // Окружение (время суток, скайбокс, туман) - if (state.scene.environment && this.environment) { - this.environment.load(state.scene.environment); - } - // Аудио (фоновая музыка/амбиент) - if (state.scene.audio && this.audioManager) { - this.audioManager.load(state.scene.audio); - } - - // Редактор-камера - if (state.editorCamera && this.camera) { - const c = state.editorCamera; - if (c.position) this.camera.position = new Vector3(c.position.x, c.position.y, c.position.z); - if (c.rotation) this.camera.rotation = new Vector3(c.rotation.x, c.rotation.y, c.rotation.z); - } - // Финальный прогресс — UI скроет overlay - if (typeof window !== 'undefined') { - window.__kubikonLoadProgress = { percent: 100, label: 'Готово!', ts: performance.now() }; - } - } - - /** - * Задача 08: активировать pointer-примитивы из палитры в реальные стрелки. - * Маркер-сфера прячется, через BeamManager создаётся анимированная стрелка - * (лента + парящий quest-marker) от источника к цели. from/to — из инспектора. - */ - _activatePointers() { - const pm = this.primitiveManager; - const bm = this.beamManager; - if (!pm || !bm) return; - for (const inst of pm.instances.values()) { - if (inst.type !== 'pointer') continue; - try { if (inst.mesh) inst.mesh.setEnabled(false); } catch (e) {} - const at = { x: inst.x, y: inst.y, z: inst.z }; - const from = this._pointerRefOrPoint(inst.pointerFrom, at); - const to = this._pointerRefOrPoint(inst.pointerTo, { x: at.x, y: at.y, z: at.z + 4 }); - try { - bm.addPointer({ - from, to, - preset: inst.pointerPreset || 'guide', - color: inst.color, textureSpeed: inst.textureSpeed, - curved: inst.curved, curveHeight: inst.curveHeight, - }); - } catch (e) { - console.warn('[BabylonScene] addPointer failed:', e); - } - } - } - - /** from/to стрелки: 'player' | id примитива/модели → ref | точка-fallback. */ - _pointerRefOrPoint(val, fallbackPoint) { - if (val === 'player') return 'player'; - if (val != null && val !== '') { - const n = Number(val); - if (Number.isFinite(n)) { - if (this.primitiveManager?.instances?.has(n)) return 'primitive:' + n; - if (this.modelManager?.instances?.has(n)) return 'model:' + n; - } - if (typeof val === 'string' - && (val.startsWith('primitive:') || val.startsWith('model:'))) return val; - } - return fallbackPoint; - } - - /** Выйти из режима игры — восстановить редактор-камеру. */ - exitPlayMode() { - if (!this._isPlaying) return; - this._isPlaying = false; - // Задача 04: закрываем любой активный модал чтобы не «висел» при стопе - try { this.modalManager?._instantClose?.(); } catch (e) {} - // Сбрасываем таймер прохождения - this._timerRunning = false; - this._timerStartedAt = null; - // Отключаем picking voxel-террейна обратно (нужно только в play). - try { this.terrainManager?.enablePickingForCamera?.(false); } catch (e) {} - // Размораживаем world-matrix статичных моделей — в редакторе - // пользователь может двигать их через гизмо. - try { this.modelManager?.unfreezeStaticModels?.(); } catch (e) {} - try { this.primitiveManager?.unfreezeStaticPrimitives?.(); } catch (e) {} - // Возвращаем все примитивы в видимое состояние (LOD-cull сбрасывается) - if (this.primitiveManager) { - for (const data of this.primitiveManager.instances.values()) { - const m = data.mesh; - if (m && m._kubikonPrimCulled === true) { - m.setEnabled(data.visible !== false); - m._kubikonPrimCulled = false; - } - } - } - - // Останавливаем пользовательские скрипты ПЕРЕД уничтожением player'а, - // чтобы скрипты не успели потрогать player в момент disposal. - if (this.gameRuntime) { - this.gameRuntime.stop(); - this.gameRuntime = null; - } - - // Останавливаем 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; - } - } -} +/** + * BabylonScene — обёртка над Babylon.js Engine + Scene с Roblox-style навигацией. + * + * Управление камерой (как в Roblox Studio): + * - ПКМ + drag : повернуть камеру (yaw/pitch вокруг своей оси) + * - ПКМ + WASD : полёт (вперёд/назад/влево/вправо относительно взгляда) + * - ПКМ + Q/E : вниз/вверх по миру + * - ПКМ + Shift : ускоренный полёт (×2.5) + * - Колесо : zoom (приближение по оси взгляда) + * - Средняя кнопка drag : pan (сдвиг параллельно экрану) + * - F : фокус на (0,0,0) — будет на выбранный объект позже + * + * Используем UniversalCamera + ручной обработчик мыши/клавиш для точной + * имитации Roblox-controls (стандартные attachControl делают не то что нужно). + * + * Этап 1, неделя 1: только сцена, камера и пол с сеткой. Блоки и физика — позже. + */ +import { + Engine, + Scene, + UniversalCamera, + Vector3, + Color3, + Color4, + HemisphericLight, + DirectionalLight, + ShadowGenerator, + CascadedShadowGenerator, + SSAORenderingPipeline, + MeshBuilder, + StandardMaterial, + DynamicTexture, + UtilityLayerRenderer, + TransformNode, + ParticleSystem, + Texture, + Ray, + Tools as BabylonTools, +} from '@babylonjs/core'; +import { BlockManager } from './BlockManager'; +import { TerrainManager, VOXEL_SIZE as TERRAIN_VOXEL_SIZE, TERRAIN_MATERIALS as TERRAIN_MATERIAL_DEFS } from './TerrainManager'; +// Этап 1 voxel-движка: новые классы chunks-based архитектуры (см. +// RUBLOX_VOXEL_ENGINE_PLAN.md). Пока работают параллельно с legacy +// TerrainManager как shadow-копия — для замеров статистики чанков и +// готовности к Этапу 2 (greedy meshing). +import { VoxelWorld } from './voxel/VoxelWorld'; +import { VoxelRenderer } from './voxel/VoxelRenderer'; +import { WorldGenerator, DEFAULT_GENERATOR_PARAMS } from './voxel/WorldGenerator'; +// Этап 6: deco-слой 0.05м — мелкие воксельные декорации (цветы/грибы/трава). +import { DecoManager } from './DecoManager'; +import { GRASS_MODELS_POOL } from './DecoModels'; +import { placeVoxelTree, TREE_TYPES } from './VoxelTreeBuilder'; +import { UserModelManager, parseUserModelId, USER_MODEL_PREFIX } from './UserModelManager'; +import { ModelManager } from './ModelManager'; +import { PrimitiveManager } from './PrimitiveManager'; +import { BillboardUiManager } from './BillboardUiManager'; +import { getPrimitiveType } from './PrimitiveTypes'; +import { FolderManager } from './FolderManager'; +import { GuiManager } from './GuiManager'; +import { ModalManager } from './ModalManager'; +import { InventoryManager } from './InventoryManager'; +import { WeaponSystem } from './WeaponSystem'; +import { ZombieManager } from './ZombieManager'; +import { NpcManager } from './NpcManager'; +import { ConstraintManager } from './ConstraintManager'; +import { BeamManager } from './BeamManager'; +import { PlacementManager } from './PlacementManager'; +import { ShopInventoryUi } from './ShopInventoryUi'; +import { ZombieSpawnerManager } from './ZombieSpawnerManager'; +import { DynamicsManager } from './DynamicsManager'; +import { Environment } from './Environment'; +import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager'; +import { GameAudioManager } from './GameAudioManager'; +import { AssetManager } from './AssetManager'; +import { SoundLibrary } from './SoundLibrary'; +import { SoundManager } from './SoundManager'; +import { GlbLibrary } from './GlbLibrary'; +import { GdLevelManager } from './GdLevelManager'; +import { GdSkybox } from './GdSkybox'; +import { GdGroundSkin } from './GdGroundSkin'; +import { GdSpikes } from './GdSpikes'; +import { GdStartArch } from './GdStartArch'; +import { GdPortalArch } from './GdPortalArch'; +import { GdDiamond } from './GdDiamond'; +import { GdPlayerModeSkin } from './GdPlayerModeSkin'; +import { GdFinish } from './GdFinish'; +import { GdForest } from './GdForest'; +import { GdPlayerCube } from './GdPlayerCube'; +import { GdPlayerTrail } from './GdPlayerTrail'; +import { GdPostFx } from './GdPostFx'; +import { PhysicsAABB } from './PhysicsAABB'; +import { PlayerController } from './PlayerController'; +import { SelectionManager } from './SelectionManager'; +import { GizmoController } from './GizmoController'; +import { HistoryManager } from './HistoryManager'; +import { GameRuntime } from './GameRuntime'; +import { attachConsoleHook, devlogReset } from './devlog'; +import { TerrainMesh, CHUNK_SIZE as TERRAIN_MESH_CHUNK } from './terrain/TerrainMesh'; +import { VoxelGrid } from './terrain/VoxelGrid'; +import { RobloxTerrain, CHUNK_SIZE as ROBLOX_CHUNK_SIZE } from './robloxterrain/RobloxTerrain'; +import { DensityGrid as RobloxDensityGrid, CELL_SIZE as ROBLOX_CELL_SIZE } from './robloxterrain/DensityGrid'; +import { SmoothDecoManager } from './robloxterrain/SmoothDecoManager'; + +export class BabylonScene { + /** + * @param {HTMLCanvasElement} canvas — DOM-элемент для рендера + */ + constructor(canvas) { + // DevLog: на localhost подключаем перехват console.* для записи в файл + // на твоей машине (c:\...\dev-tools\devlog.txt). Это даёт Claude + // возможность читать свежие логи без копипасты вручную. + try { + devlogReset(); + attachConsoleHook(); + } catch (e) {} + this.canvas = canvas; + this.engine = null; + this.scene = null; + this.camera = null; + + // Состояние ввода. Храним КОДЫ клавиш (e.code), не key — чтобы + // работало на русской раскладке: KeyW не зависит от языка ввода. + this._codes = new Set(); + this._shiftDown = false; + this._isRotating = false; // ПКМ зажата → крутим камеру + this._isPanning = false; // СКМ зажата → pan + this._lastMouseX = 0; + this._lastMouseY = 0; + + // Параметры + this.MOVE_SPEED = 12; // юнитов/секунду при WASD + this.SHIFT_MULTIPLIER = 2.5; + this.ROTATE_SENSITIVITY = 0.0035; // радиан/пиксель + this.ZOOM_SPEED = 1.5; + this.PAN_SENSITIVITY = 0.025; + + // Состояние редактора блоков + this.blockManager = null; + this.modelManager = null; + this.primitiveManager = null; + this.folderManager = null; + this.guiManager = null; // 2D-UI слой (Frame/Text/Button/Image) + this.inventory = null; // инвентарь игрока (9 слотов hot-bar) + this.weapons = null; // система оружия (создаётся при enterPlayMode) + this.zombieManager = null; // AI зомби (создаётся при enterPlayMode) + this.npcManager = null; // управляемые скриптом NPC (Фаза 4.1) + this.constraintManager = null; // связи объектов (Фаза 5, Constraints) + this.beamManager = null; // лучи и следы (Фаза 5.2) + // Placement mode (задача 11) — фича-парность со студией. + this.placementManager = null; + this.shopInventoryUi = null; + this._PlacementManagerClass = PlacementManager; + this._ShopInventoryUiClass = ShopInventoryUi; + this.spawnerManager = null; // спавнеры зомби + this.environment = null; + this.audioManager = null; + this.assetManager = null; // библиотека пользовательских картинок + this.soundLibrary = null; // библиотека пользовательских звуков (Фаза 5.5) + this.soundManager = null; // 3D-воспроизведение звука (Play-only) + this.glbLibrary = null; // импортированные .glb-модели (Фаза 5.8) + this.selection = null; // SelectionManager + // Тач-режим (мобилки/планшеты) — выставляется снаружи через + // setTouchMode() ДО enterPlayMode. Влияет на PlayerController. + this._touchMode = false; + this._activeTool = 'block'; // 'select' | 'block' | 'model' | 'primitive' | 'erase' + this._activeBlockType = 'grass'; + this._activeModelType = null; + this._activePrimitiveType = 'cube'; + this._ghostMesh = null; + this._ghostRotationY = 0; // угол поворота ghost-модели (R = +90°) + this._gizmo = null; + this._gizmoLayer = null; + this._gizmoDragging = false; // флаг что идёт drag гизмо + this._isDragPlacing = false; // флаг drag-постановки/удаления блоков + this._isTerrainBrushing = false; // флаг drag-кисти террейна + this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять + this._lastPlacedKey = null; // последняя клетка чтобы не ставить дважды + this._dragLockAxis = null; // 'y' | 'x' | 'z' — плоскость зафиксированная первым блоком + this._dragLockValue = 0; // значение по фиксированной оси + + // Точка спавна игрока в режиме Play (обновляется setSpawnPoint) + this._spawnPoint = { x: 0, y: 5, z: 0 }; + // Модель персонажа для режима Play. + // Дефолт — R15-скин bacon-hair (классический Roblox-вид). + // 'skin_*' грузится из characters//body.glb (R15-скелет), + // 'character-*' — старые Kenney-модели. + this._playerModelType = 'skin_bacon-hair'; + // Размер пола: пол идёт от -worldHalf до +worldHalf по X и Z. + // По умолчанию 80×80 (worldHalf = 40). Можно менять через setWorldSize(). + this._worldHalf = 40; + // Видимость пола (можно «удалить» — пол исчезнет визуально и из физики) + this._floorEnabled = true; + // Множитель силы прыжка (1 = базовый, 1.5 = в 1.5 раза выше) + this._jumpPowerMul = 1; + // Прицел в Play: 'none' | 'dot' | 'cross' | 'circle'. По умолчанию выключен. + this._crosshair = 'none'; + + // Скрипты пользователя (массив { id, code, target? }). + // На этапе 2.1 — только один глобальный «scene script», без UI редактирования. + // Хранится в проекте через serialize/loadFromState. + this._scripts = []; + this.gameRuntime = null; // GameRuntime создаётся при enterPlayMode + + // Режим Play + this.player = null; // PlayerController когда играем + this.physics = null; // PhysicsAABB + this._editorCameraSnapshot = null; // запоминаем позицию редактор-камеры + this._isPlaying = false; + + // Drag-detection: чтобы не ставить блок при rotate (mouseup без movement + // = клик; с movement = drag). + this._mouseDownTime = 0; + this._mouseDownX = 0; + this._mouseDownY = 0; + this._mouseDownButton = -1; + + // Слушатели — храним чтобы корректно отписаться + this._listeners = []; + this._resizeHandler = null; + } + + init() { + // На тач-устройствах сразу отключаем anti-aliasing — это даёт + // заметный буст FPS на мобилах. Anti-aliasing полезен только на + // больших мониторах с низким DPR. + const isTouchDevice = (typeof window !== 'undefined') && ( + 'ontouchstart' in window || (navigator.maxTouchPoints || 0) > 0 + ); + const isSmallScreen = (typeof window !== 'undefined') + && window.innerWidth <= 1024; + const useAA = !(isTouchDevice && isSmallScreen); + // MOBILE-OPT (этап 1): флаг для всех мобильных оптимизаций + // Можно принудительно отключить через ?desktop=1 в URL (для отладки). + const forceDesktop = (typeof window !== 'undefined') + && new URLSearchParams(window.location.search).has('desktop'); + this._isMobileMode = (isTouchDevice && isSmallScreen) && !forceDesktop; + this.engine = new Engine(this.canvas, useAA, { + preserveDrawingBuffer: true, + stencil: true, + // Parallel shader compile — критично для устранения фризов при + // повороте камеры. Когда новый material попадает во frustum, + // Babylon без этого синхронно компилит shader и блокирует UI. + // С параллельным compile рендер использует fallback shader и + // переключается на оптимизированный когда тот готов. + useHighPrecisionFloats: false, + powerPreference: 'high-performance', + }, true); + // MOBILE-OPT (этап 1.5): hardware scaling ОТКЛЮЧЁН. + // Логи показали что узкое место — CPU (draw calls растут до 60k), + // а не GPU fillrate. Скейлинг ухудшал картинку и не помогал FPS. + + // PERF-METRICS: счётчики для perf-overlay. Накопительно за окно + // сэмплинга 5сек, потом overlay читает и сбрасывает. + this._perfMetrics = { + render_ms_sum: 0, render_count: 0, + physics_ms_sum: 0, physics_count: 0, + script_ms_sum: 0, script_count: 0, + // Замер idle-времени между концом prev-render и началом next-render. + // Если idle ≈ frame_ms - render_ms — значит мы GPU-bound (JS-поток + // ждёт GPU/V-Sync). Если idle мал — CPU-bound (что-то ещё в JS ест). + idle_ms_sum: 0, idle_count: 0, + _lastRenderEnd: 0, + }; + + this.scene = new Scene(this.engine); + this.scene.clearColor = new Color4(0.5, 0.7, 0.9, 1.0); + // ambient: материалы TerrainManager ставят mat.ambientColor=(1,1,1), + // но без scene.ambientColor != 0 это умножается на 0 и боковые грани + // вокселей остаются чёрными (направленный свет не освещает их с + // sunset preset). (0.3,0.3,0.3) даёт мягкое всестороннее освещение, + // не пересвечивает существующие сцены. + this.scene.ambientColor = new Color3(0.3, 0.3, 0.3); + // Глобальный хендл для отладки из консоли: window.__BS — это инстанс + // BabylonScene; window.__SC — Babylon scene; window.__ENG — engine. + // window.__BJS — набор Babylon-классов для dev-инструментов + // (@babylonjs/core модульный, window.BABYLON не существует) — + // им пользуется съёмщик hero-кадров dev-tools/wiki-shots/shoot-hero.js. + if (typeof window !== 'undefined') { + window.__BS = this; + window.__SC = this.scene; + window.__ENG = this.engine; + window.__BJS = { UniversalCamera, Vector3, Tools: BabylonTools }; + } + // ВАЖНО: blockMaterialDirtyMechanism НЕ включаем здесь. + // Когда true — ставим свойства материала (emissiveColor/disableLighting/ + // alpha) у новых мешей (трейсеры выстрелов, debris при смерти, + // муззл-флэш, импакт), но шейдер пересчитывается с дефолтами и эти + // свойства не применяются. Эффект: трейсер/дебрис создаются, но + // НЕ ВИДНЫ. Включать только локально вокруг массовых операций + // (если когда-то появится нужда), сразу выключая обратно. + // Skip pointer-move picking — не делаем raycast от мыши на каждый + // mousemove. Игроку важны клик и hover-через-canvas, а не каждый move. + this.scene.skipPointerMovePicking = true; + // Параллельная компиляция шейдеров — фоновая компиляция новых + // материалов без блокировки рендера (если поддержано WebGL2). + if (this.engine.getCaps?.()?.parallelShaderCompile !== undefined) { + try { + // Babylon 6+ — это просто флаг capability, выставляется + // автоматически при поддержке. Логируем для отладки. + // eslint-disable-next-line no-console + console.log('[BabylonScene] parallel shader compile:', + !!this.engine.getCaps().parallelShaderCompile); + } catch (e) {} + } + + // Возвращаем detachControl — наши mousedown-listeners на canvas с + // capture=true должны работать без вмешательства Babylon-pointerHandler. + // Гизмо запустим вручную через прямые pointerdown/move/up на utility-сцене. + this.scene.detachControl(); + + this._createCamera(); + this._createLights(); + this._createGroundGrid(); + this._createGhostBlock(); + this._createSpawnMarker(); + this._setupInputControls(); + + // Менеджеры объектов + this.blockManager = new BlockManager(this.scene); + // При создании нового proto-меша блока — сразу регистрируем его + // как shadow caster (если генератор уже создан). + this.blockManager.setOnProtoCreated((proto) => { + this.addShadowCaster(proto); + }); + + // Менеджер декораций — Этап 6 voxel-движка. + // Мини-воксели 0.05м для цветов/грибов/травы. Без коллизий. + this.decoManager = new DecoManager(this.scene); + this.decoManager.setOnChange(() => { + if (this._onSceneChange) this._onSceneChange(); + }); + + // Менеджер ландшафта — отдельный voxel-слой 1×1×1, рисуемый кистями. + // Использует thin-instances per материал, как BlockManager. + this.terrainManager = new TerrainManager(this.scene); + // ОПТИМИЗАЦИЯ: НЕ регистрируем terrain как shadow caster. Большая + // карта с 150K voxel'ов в shadow renderList даёт +50-100% нагрузки + // на GPU. Тени от деревьев на земле выглядят не критично, а receiveShadows + // оставлен — тени от других объектов (моделей) показываются. + // this.terrainManager.setOnProtoCreated((proto) => { + // this.addShadowCaster(proto); + // }); + this.terrainManager.setOnChange(() => { + // Пометить сцену как изменённую — автосохранение подхватит. + // Имя коллбэка — _onSceneChange (то же что у blockManager/ + // modelManager/primitiveManager). Раньше тут было _onChange — + // несуществующее поле, из-за чего террейн не сохранялся + // автоматически. Только ручная кнопка «Сохранить» дёргает + // serialize() напрямую и попадала в БД. + if (this._onSceneChange) this._onSceneChange(); + }); + + // === Этап 1 voxel-движка: shadow-копия террейна в новой архитектуре === + // Параллельно с TerrainManager работает VoxelWorld с теми же voxel'ами, + // но в формате chunks 32×32×32. Пока БЕЗ рендера (флаг useVoxelWorld= + // false) — только структура данных для замера chunk-статистики и + // подготовки к Этапу 2. + // window.__voxelWorldStats() — выведет в консоль текущую статистику. + // window.__voxelWorldRender(true/false) — переключит рендер на новый + // (когда будет готов greedy). Сейчас рендерит дублирующиеся mesh'и + // поверх старых — для визуальной валидации. + this.voxelWorld = new VoxelWorld(); + this.voxelWorld.setOnChange(() => { + // Авто-rebuild dirty чанков при изменении (только если рендер включён) + if (this._voxelRenderEnabled && this.voxelRenderer) { + this.voxelRenderer.rebuildDirty(); + } + }); + this.voxelRenderer = new VoxelRenderer(this.voxelWorld, this.scene); + this.voxelRenderer.setOnMeshCreated((m) => this.addShadowCaster(m)); + /** Включить/выключить рендер VoxelWorld. По умолчанию false — только + * loadFromArray в VoxelWorld для статистики, без отображения. */ + this._voxelRenderEnabled = false; + /** Этап 4 streaming: рендерить только чанки в радиусе от камеры. + * false по умолчанию — рендерим все чанки (для маленьких карт). + * Включается через window.__voxelWorldStreaming(true, 64). */ + this._voxelStreamingEnabled = false; + this._voxelStreamingRadius = 64; // метров + this._voxelStreamingLastUpdate = 0; + this._voxelStreamingInterval = 250; // мс между проверками + if (typeof window !== 'undefined') { + window.__voxelWorldStats = () => { + const s = this.voxelWorld.stats(); + console.log('[VoxelWorld stats]', s); + return s; + }; + // Диагностика FPS bottleneck'ов на больших картах. + // Запускать в консоли когда лагает: window.__voxelPerfReport() + // Debug-команды для диагностики FPS-проблем. + // Запускать в консоли — увидим что реально жрёт CPU/GPU. + window.__toggleShadows = (on) => { + this.setShadowQuality(on === false ? 'off' : 'soft'); + console.log('[Debug] shadows:', on === false ? 'OFF' : 'ON'); + }; + window.__togglePostProcess = (on) => { + if (this.scene && this.scene.postProcessRenderPipelineManager) { + // Babylon не умеет тривиально выключать pipeline, поэтому + // просто отключаем все pipelines + const enabled = on !== false; + if (this._postProcessPipelines) { + for (const p of this._postProcessPipelines) { + try { p.setEnabled(enabled); } catch (e) {} + } + } + } + console.log('[Debug] post-process:', on === false ? 'OFF' : 'ON'); + }; + window.__toggleSceneOptim = (on) => { + // Глобальные оптимизации Babylon + const scn = this.scene; + if (on !== false) { + scn.freezeActiveMeshes(); + scn.skipFrustumClipping = true; + scn.blockfreeActiveMeshesAndRenderingGroups = true; + console.log('[Debug] scene optim: freezeActiveMeshes + skipFrustumClipping ON'); + } else { + scn.unfreezeActiveMeshes(); + scn.skipFrustumClipping = false; + scn.blockfreeActiveMeshesAndRenderingGroups = false; + console.log('[Debug] scene optim: OFF'); + } + }; + window.__voxelPerfReport = () => { + const tm = this.terrainManager; + if (!tm) return console.warn('no terrainManager'); + const scn = this.scene; + const eng = scn.getEngine(); + const totalMeshes = scn.meshes.length; + let activeMeshes = 0; + let activeRegionMeshes = 0; + let activeDecoMeshes = 0; + for (const m of scn.meshes) { + if (m.isEnabled() && m.material) activeMeshes++; + } + if (tm._regionMeshes) { + for (const m of tm._regionMeshes.values()) { + if (m.isEnabled()) activeRegionMeshes++; + } + } + if (this.decoManager?._chunkMeshes) { + for (const colorMap of this.decoManager._chunkMeshes.values()) { + for (const m of colorMap.values()) { + if (m.isEnabled()) activeDecoMeshes++; + } + } + } + // FPS: Babylon engine.getFps() даёт усреднённый, instantaneous + // (1000/getDeltaTime) скачет хаотично из-за GC. + const stableFps = eng.getFps?.() ?? (1000 / eng.getDeltaTime()); + const instFps = 1000 / eng.getDeltaTime(); + // Подсчёт активных треугольников и draw calls. + // Babylon хранит sceneInstrumentation, но он opt-in. + // Считаем вручную из активных мешей. + let activeTriangles = 0; + let activeVertices = 0; + let activeDrawCalls = 0; + if (this.scene && this.scene.meshes) { + for (const m of this.scene.meshes) { + if (!m.isEnabled() || !m.material) continue; + // Frustum cull skip + const idxCount = m.getTotalIndices?.() ?? 0; + if (idxCount === 0) continue; + // thin-instances умножают + const instCount = m.thinInstanceCount > 0 ? m.thinInstanceCount : 1; + activeTriangles += (idxCount / 3) * instCount; + activeVertices += (m.getTotalVertices?.() ?? 0) * instCount; + // 1 draw call на меш (multimat = +submeshes) + const subMeshes = m.subMeshes ? m.subMeshes.length : 1; + activeDrawCalls += subMeshes; + } + } + // Frame time: целевые значения + // 60 FPS = 16.6мс/кадр + // 30 FPS = 33.3мс/кадр + // 23 FPS = 43.5мс/кадр ← наша проблема + const frameMs = eng.getDeltaTime(); + // Visibility — Chrome даёт throttle до 20 FPS если таб неактивен + const docHidden = typeof document !== 'undefined' && document.hidden; + const winFocused = typeof document !== 'undefined' && document.hasFocus?.(); + // PERF-DIAG: где теряется время? + // render_ms — сколько занимает scene.render() (GPU + babylon) + // idle_ms — промежуток между концом render и началом + // следующего кадра (если велик — GPU-bound + // ИЛИ браузер throttle; если мал, а frame_ms + // большой — узкое место в нашем JS до render). + const pm = this._perfMetrics; + const renderMsAvg = pm && pm.render_count + ? (pm.render_ms_sum / pm.render_count) : 0; + const idleMsAvg = pm && pm.idle_count + ? (pm.idle_ms_sum / pm.idle_count) : 0; + // Сбрасываем накопители — следующий отчёт за свежий период. + if (pm) { + pm.render_ms_sum = 0; pm.render_count = 0; + pm.idle_ms_sum = 0; pm.idle_count = 0; + } + console.log('[PerfReport]', { + fps_stable: stableFps.toFixed(1), + fps_instant: instFps.toFixed(1), + frame_ms: frameMs.toFixed(1), + render_ms: renderMsAvg.toFixed(1), + idle_ms: idleMsAvg.toFixed(1), + isPlaying: this._isPlaying, + triangles_K: (activeTriangles / 1000).toFixed(0) + 'K', + drawCalls: activeDrawCalls, + tab_hidden: docHidden, + win_focused: winFocused, + voxelCount: tm.voxels?.size ?? 0, + sceneMeshes: totalMeshes, + activeMeshes, + regionMeshes: tm._regionMeshes?.size ?? 0, + activeRegionMeshes, + decoMeshes: this.decoManager?._chunkMeshes ? this._decoMeshCount() : 0, + activeDecoMeshes, + streamingRadius: this._terrainStreamingRadius, + // Новый TerrainMesh (Roblox-style, voxel) + tmesh_chunks: this._terrainMesh?.chunks?.size ?? 0, + tmesh_pending: this._terrainMesh?._pendingChunks?.size ?? 0, + tmesh_tris: this._terrainMesh ? this._terrainMesh.getActiveTriangles() : 0, + // Roblox Smooth Terrain + rt_chunks: this._robloxTerrain?.chunks?.size ?? 0, + rt_pending: this._robloxTerrain?._pendingChunks?.size ?? 0, + rt_tris: this._robloxTerrain ? this._robloxTerrain.getStats().triangles : 0, + }); + }; + this._decoMeshCount = () => { + let n = 0; + if (!this.decoManager?._chunkMeshes) return 0; + for (const m of this.decoManager._chunkMeshes.values()) n += m.size; + return n; + }; + // === LEAK DETECTOR (dev-only) === + // Если sceneMeshes растёт без явной причины — каждый snapshot + // запоминаем имена мешей, на след. snapshot печатаем НОВЫЕ. + // Точно покажет утечку: какие меши накапливаются. + // Использовать: window.__leakSnap(); потом подождать 5 сек, + // снова window.__leakSnap() — выведет diff. + let _leakLastNames = null; + window.__leakSnap = () => { + const names = this.scene.meshes.map(m => m.name || ''); + if (_leakLastNames === null) { + _leakLastNames = new Map(); + for (const n of names) _leakLastNames.set(n, (_leakLastNames.get(n) || 0) + 1); + console.log('[LeakSnap] baseline:', names.length, 'мешей. Подожди 5+ сек и зови __leakSnap() снова.'); + return; + } + const cur = new Map(); + for (const n of names) cur.set(n, (cur.get(n) || 0) + 1); + const diff = {}; + let totalDiff = 0; + for (const [n, c] of cur) { + const prev = _leakLastNames.get(n) || 0; + if (c > prev) { diff[n] = `+${c - prev} (теперь ${c})`; totalDiff += c - prev; } + } + for (const [n, c] of _leakLastNames) { + if (!cur.has(n)) { diff[n] = `-${c} (удалён)`; totalDiff -= c; } + } + console.log('[LeakSnap] diff:', totalDiff > 0 ? `+${totalDiff}` : totalDiff, diff); + _leakLastNames = cur; + }; + // Автомониторинг FPS — каждые 2 сек пишет PerfReport в devlog. + // Активируется автоматически на localhost. На прод не работает. + window.__perfMonitorStart = (interval = 2000) => { + if (window.__perfMonitorTimer) { + clearInterval(window.__perfMonitorTimer); + } + window.__perfMonitorTimer = setInterval(() => { + try { window.__voxelPerfReport?.(); } catch (e) {} + }, interval); + console.log(`[PerfMonitor] started, interval=${interval}ms`); + }; + window.__perfMonitorStop = () => { + if (window.__perfMonitorTimer) { + clearInterval(window.__perfMonitorTimer); + window.__perfMonitorTimer = null; + console.log('[PerfMonitor] stopped'); + } + }; + // Автостарт мониторинга на localhost — Claude читает devlog.txt + if (typeof window !== 'undefined' + && (window.location.hostname === 'localhost' + || window.location.hostname === '127.0.0.1')) { + setTimeout(() => { try { window.__perfMonitorStart?.(2000); } catch (e) {} }, 1000); + } + // === Тест нового TerrainMesh (Roblox/Minecraft-style) === + // + // Создаёт VoxelGrid и заполняет его holmistym ландшафтом из + // sin-волн. Рендерится через Greedy Meshing. + // Использование в DevTools: + // __terrainTest(64) — небольшая карта 64×16×64м + // __terrainTest(150) — большая 150×24×150м + // __terrainTest(250) — целевая 250×32×250м + window.__terrainTest = (sizeMeters = 64) => { + if (!this._terrainMesh) { + this._terrainMesh = new TerrainMesh(this.scene); + } + const tm = this._terrainMesh; + // Удалим старый legacy terrain — он перекрывает картинку + try { + if (this.terrainManager) this.terrainManager.clear(); + if (this.decoManager) this.decoManager.clear(); + } catch (e) {} + tm.disposeAll(); + + const t0 = performance.now(); + const sx = sizeMeters, sz = sizeMeters; + const sy = 32; + const grid = new VoxelGrid({ + origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 }, + size: { x: sx, y: sy, z: sz }, + }); + // Заполняем heightmap-картой: y = базовый + sin(x)*cos(z) + for (let z = 0; z < sz; z++) { + for (let x = 0; x < sx; x++) { + const fx = (x - sx / 2) / sx; + const fz = (z - sz / 2) / sz; + const h = Math.floor( + 6 + Math.sin(fx * Math.PI * 3) * 4 + + Math.cos(fz * Math.PI * 4) * 3 + + Math.sin((fx + fz) * Math.PI * 6) * 2, + ); + for (let y = 0; y < h && y < sy; y++) { + let mat; + if (y === h - 1) mat = 'grass'; + else if (y >= h - 3) mat = 'dirt'; + else mat = 'rock'; + grid.set(x, y, z, mat); + } + } + } + const tFill = performance.now() - t0; + const solid = grid.countSolid(); + console.log(`[TerrainTest] filled grid ${sx}×${sy}×${sz} (${solid} solid voxels) in ${tFill.toFixed(0)}ms`); + + tm.loadFromGrid(grid); + + // Сразу материализуем ВСЕ chunks (для теста, не lazy) + const t1 = performance.now(); + const camX = this.camera?.position.x || 0; + const camZ = this.camera?.position.z || 0; + const r = tm.updateStreaming(camX, camZ, 9999, { maxBuild: 9999 }); + const tBuild = performance.now() - t1; + const tris = tm.getActiveTriangles(); + console.log(`[TerrainTest] built ${r.built} chunks in ${tBuild.toFixed(0)}ms — ${tris} triangles total`); + console.log(`[TerrainTest] open DevTools → __voxelPerfReport() через 2 сек → должно быть 60+ FPS`); + }; + + // Удалить тестовый terrain mesh + window.__terrainTestClear = () => { + if (this._terrainMesh) { + this._terrainMesh.disposeAll(); + console.log('[TerrainTest] cleared'); + } + }; + + // ============================================================ + // Roblox-style Smooth Terrain test + // ============================================================ + // + // Использование в DevTools: + // __robloxTest(50) — карта 50×16×50 ячеек = 200×64×200 м + // __robloxTest(125) — 500×64×500 м (огромная, ОК для smooth) + // + // Создаёт holmistyy ландшафт через density-функцию и рендерит + // через Surface Nets. Проверка что архитектура работает. + window.__robloxTest = async (gridSize = 50, userParams = null) => { + if (!this._robloxTerrain) { + this._robloxTerrain = new RobloxTerrain(this.scene); + // Подключить к физике — иначе игрок проваливается в smooth terrain + if (this.physics?.setRobloxTerrain) { + this.physics.setRobloxTerrain(this._robloxTerrain); + } + } + const rt = this._robloxTerrain; + try { + if (this.terrainManager) this.terrainManager.clear(); + if (this.decoManager) this.decoManager.clear(); + if (this.voxelWorld) { + const layer = this.voxelWorld.getLayer?.('terrain'); + if (layer && layer.clear) layer.clear(); + } + this._terrainStreamingEnabled = false; + } catch (e) {} + rt.disposeAll(); + + const t0 = performance.now(); + const sx = gridSize, sz = gridSize; + const sy = 24; // высота карты в cells: 24 × 4м = 96м (для гор) + const grid = new RobloxDensityGrid({ + origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 }, + size: { x: sx, y: sy, z: sz }, + }); + + // === Используем тот же WorldGenerator что и voxel-генератор === + // 1 smooth-cell = 4м = 16 voxel-units. + // sampleHeight возвращает высоту в voxel-units (0.25м). + // sampleBiome → объект {topMaterial,softMaterial,hardMaterial,...}. + // + // userParams приходит из UI (TerrainGenPanel buildParams). + // Если null — берём дефолтные. + const params = userParams + ? JSON.parse(JSON.stringify(userParams)) + : JSON.parse(JSON.stringify(DEFAULT_GENERATOR_PARAMS)); + console.log(`[RobloxTest] params: amp=${params.heightmap.amplitude}, scale=${params.heightmap.scale}, exp=${params.heightmap.exponent}, biomes=${params.biomes?.length}`); + const gen = new WorldGenerator(params); + + // Маппинг материалов voxel-генератора → smooth (DensityGrid + // поддерживает только grass/rock/sand/snow). + // dirt → grass, остальные пропускаются. + const matMap = (m) => { + if (m === 'dirt') return 'grass'; + if (m === 'grass' || m === 'rock' || m === 'sand' || m === 'snow') return m; + return 'grass'; + }; + + // Шаг 1: heightmap + biome для каждой smooth-cell. + // x,z в smooth-grid — переводим в voxel-units: vx = (x + origin.x) * 16 + const CELL_VOXELS = 16; // 4м / 0.25м per voxel = 16 + const heightMap = new Float32Array(sx * sz); + const topMats = new Array(sx * sz); + const softMats = new Array(sx * sz); + const hardMats = new Array(sx * sz); + for (let z = 0; z < sz; z++) { + for (let x = 0; x < sx; x++) { + const vx = (x + grid.origin.x) * CELL_VOXELS + CELL_VOXELS / 2; + const vz = (z + grid.origin.z) * CELL_VOXELS + CELL_VOXELS / 2; + const hVoxels = gen.sampleHeight(vx, vz); + const biome = gen.sampleBiome(vx, vz); + const hCells = hVoxels / CELL_VOXELS; + heightMap[z * sx + x] = hCells; + topMats[z * sx + x] = matMap(biome.topMaterial); + softMats[z * sx + x] = matMap(biome.softMaterial); + hardMats[z * sx + x] = matMap(biome.hardMaterial); + } + } + + // Шаг 2: density + материалы. + // Топ-слой: topMaterial биома. + // Средний (1..3 cells вглубь): softMaterial. + // Глубокий (>3 cells): hardMaterial. + for (let z = 0; z < sz; z++) { + for (let x = 0; x < sx; x++) { + const h = heightMap[z * sx + x]; + const topMat = topMats[z * sx + x]; + const softMat = softMats[z * sx + x]; + const hardMat = hardMats[z * sx + x]; + const h1 = x > 0 ? heightMap[z * sx + (x - 1)] : h; + const h2 = x < sx - 1 ? heightMap[z * sx + (x + 1)] : h; + const h3 = z > 0 ? heightMap[(z - 1) * sx + x] : h; + const h4 = z < sz - 1 ? heightMap[(z + 1) * sx + x] : h; + const slope = Math.max( + Math.abs(h - h1), Math.abs(h - h2), + Math.abs(h - h3), Math.abs(h - h4), + ); + // На очень крутых обрывах (>3 cells = 12м перепад) — + // обнажение rock даже на травяных склонах. + const useRockSlope = slope > 3.0 && topMat !== 'sand' && topMat !== 'snow'; + + for (let y = 0; y < sy; y++) { + const delta = h - y; + let densityF; + if (delta > 2) densityF = 1; + else if (delta < -2) densityF = 0; + else densityF = (delta + 2) / 4; + const density = (densityF * 255) | 0; + if (density > 0) { + let mat; + if (useRockSlope) mat = 'rock'; + else if (delta < 1) mat = topMat; + else if (delta < 3) mat = softMat; + else mat = hardMat; + grid.set(x, y, z, density, mat); + } + } + } + } + const tFill = performance.now() - t0; + console.log(`[RobloxTest] filled grid ${sx}×${sy}×${sz} (${grid.countSolid()} solid cells) in ${tFill.toFixed(0)}ms`); + + rt.loadFromGrid(grid); + + // Материализуем ВСЕ chunks сразу для теста. + const t1 = performance.now(); + const camX = this.camera?.position.x || 0; + const camZ = this.camera?.position.z || 0; + const r = rt.updateStreaming(camX, camZ, 99999, { maxBuild: 9999 }); + const tBuild = performance.now() - t1; + const stats = rt.getStats(); + console.log(`[RobloxTest] built ${r.built} chunks in ${tBuild.toFixed(0)}ms — ${stats.triangles} triangles`); + + // Мини-карта для свежесгенерированного гладкого ландшафта. + this._setupMinimapForRobloxTerrain(); + + // === Авто-спавн над поверхностью === + // Находим в grid самую верхнюю solid-ячейку в столбце x=0, z=0. + // Spawn = top_y + 2м над ней. + const CS = 4; // CELL_SIZE + const cellX0 = 0 - grid.origin.x; // мировые (0,_,0) → cell + const cellZ0 = 0 - grid.origin.z; + let topCellY = -1; + for (let cy = sy - 1; cy >= 0; cy--) { + if (grid.isSolid(cellX0, cy, cellZ0)) { topCellY = cy; break; } + } + if (topCellY >= 0) { + const surfaceY = (grid.origin.y + topCellY + 1) * CS; + this._spawnPoint = { x: 0, y: surfaceY + 2, z: 0 }; + this._updateSpawnMarker?.(); + console.log(`[RobloxTest] auto-spawn at y=${surfaceY + 2} (surface at y=${surfaceY})`); + } + + // Отключаем baseplate-пол — иначе он закрывает обзор и + // создаёт коллизии под smooth-ландшафтом. + try { this.setFloorEnabled(false); } catch (e) {} + + // === Декорации (цветы / трава / грибы) === + // Размещаем 3D-модели Kenney Nature Kit через thin-instances. + // Используем те же sampleHeight/sampleBiome из WorldGenerator + // что и для terrain — биомы определят какие декорации куда идут. + const decoOpts = userParams?.smoothDeco ?? { + flowersDensity: 0.025, + grassDensity: 0.10, + treesDensity: 0.4, + }; + // Сохраняем параметры для сериализации (при load воссоздадим) + this._smoothDecoParams = { + flowersDensity: decoOpts.flowersDensity, + grassDensity: decoOpts.grassDensity, + treesDensity: decoOpts.treesDensity ?? 0.4, + seed: params.seed || 1337, + bbox: { + minX: -(sx * CS) / 2, maxX: (sx * CS) / 2, + minZ: -(sz * CS) / 2, maxZ: (sz * CS) / 2, + }, + // Параметры WorldGenerator нужны для воссоздания biome-маппинга + genParams: params, + }; + if (decoOpts.flowersDensity > 0 || decoOpts.grassDensity > 0 || (decoOpts.treesDensity ?? 0) > 0) { + try { + if (!this._smoothDecoManager) { + this._smoothDecoManager = new SmoothDecoManager(this.scene); + } + const tDeco0 = performance.now(); + await this._smoothDecoManager.loadAll(); + const tDecoLoad = performance.now() - tDeco0; + // bbox в мировых координатах (метры) + const halfMeters = (sx * CS) / 2; + const bbox = { + minX: -halfMeters, maxX: halfMeters, + minZ: -halfMeters, maxZ: halfMeters, + }; + // Хелпер для surface raycast (использует физику) + const sampleSurfaceY = (x, z) => { + if (!this.physics?._sampleRobloxSurface) return null; + return this.physics._sampleRobloxSurface(x, z); + }; + const sampleBiomeId = (x, z) => { + // x,z в метрах → voxel-units (×4) + const vx = x * 4; + const vz = z * 4; + const biome = gen.sampleBiome(vx, vz); + return biome?.id; + }; + const tDeco1 = performance.now(); + const r = this._smoothDecoManager.placeDecorations({ + sampleSurfaceY, sampleBiomeId, bbox, + densityFlowers: decoOpts.flowersDensity, + densityGrass: decoOpts.grassDensity, + densityTrees: decoOpts.treesDensity ?? 0, + seed: params.seed || 1337, + }); + const tDecoPlace = performance.now() - tDeco1; + console.log(`[RobloxTest] decorations: load ${tDecoLoad.toFixed(0)}ms + place ${tDecoPlace.toFixed(0)}ms → ${r.total} instances`); + // Регистрация tree-AABB в физике — игрок не пройдёт сквозь стволы. + if (this.physics?.setSmoothDecoTrees && r.treeColliders) { + this.physics.setSmoothDecoTrees(r.treeColliders); + } + } catch (e) { + console.error('[RobloxTest] decorations failed:', e); + } + } + + // Перемещаем редактор-камеру повыше чтобы видеть весь рельеф + if (this.camera && topCellY >= 0) { + const surfaceY = (grid.origin.y + topCellY + 1) * CS; + this.camera.position.x = sx * CS * 0.3; + this.camera.position.y = surfaceY + 30; + this.camera.position.z = sz * CS * 0.3; + this.camera.setTarget?.(new Vector3(0, surfaceY, 0)); + } + }; + + window.__robloxTestClear = () => this.clearRobloxTerrain(); + + // Этап 7a: процедурный генератор + // window.__voxelGenerate({size:160, params:{...}}) — генерирует + // террейн в bbox [-size..+size] и заменяет существующий terrain. + window.__voxelGenerate = async (opts = {}) => { + // ГЛОБАЛЬНЫЙ лок (на window, не на this!). + // Без него при HMR (hot module reload в dev) каждая копия + // BabylonScene имеет свой this._voxelGenerating, и команда + // в консоли вызывает все копии параллельно. + // window.__voxelGenLock виден ВСЕМ копиям сцены. + if (window.__voxelGenLock) { + console.warn('[VoxelGen] already running, ignoring duplicate call'); + return null; + } + window.__voxelGenLock = true; + // size — half-size в voxel-units (0.25м/voxel). + // Картa = size × 2 × 0.25м. + // size=160 → 80×80м (~200K voxels, FPS 27) ← по умолчанию + // size=200 → 100×100м (~400K voxels, FPS 25) ← МАКСИМУМ + // Жёсткий лимит — 200 (карта 100×100м максимум). + // Для больших карт используйте Roblox-style smooth terrain. + try { + const MAX_SIZE = 200; + let size = opts.size ?? 160; + if (size > MAX_SIZE) { + console.warn(`[VoxelGen] size=${size} превышает лимит ${MAX_SIZE} (карта >100м). Обрезаю до ${MAX_SIZE}.`); + size = MAX_SIZE; + } + const params = opts.params ?? DEFAULT_GENERATOR_PARAMS; + // Сохраняем для мини-карты (MinimapOverlay читает window.__lastGenParams) + window.__lastGenParams = params; + window.__lastGenSize = size; + console.log(`[VoxelGen] generating ${size*2}×${size*2} voxel-units (${(size * 2 * 0.25).toFixed(0)}m × ${(size * 2 * 0.25).toFixed(0)}m)…`); + + // ВАЖНО: пишем в LEGACY TerrainManager — он рендерит правильно + // (MultiCube для grass:top/side/bottom работает, текстуры + // настроены корректно). VoxelWorld остаётся как shadow-copy + // для RLE-сжатия в БД, но не для рендера. + // + // VoxelRenderer (новый) пока что выключен — он показывал + // серую кашу из-за проблем с MultiCube. + this._voxelRenderEnabled = false; + if (this.voxelRenderer) { + this.voxelRenderer.dispose(); + this.voxelRenderer = new VoxelRenderer(this.voxelWorld, this.scene); + this.voxelRenderer.setOnMeshCreated((m) => this.addShadowCaster(m)); + } + + // Progress callback — UI подхватывает через window.__voxelGenProgress. + const onProgress = (done, total, phase) => { + const pct = Math.min(100, Math.round((done / total) * 100)); + if (window.__voxelGenProgress) { + try { window.__voxelGenProgress(pct, phase); } catch (e) {} + } + }; + onProgress(0, 100, 'starting'); + + // Этап C оптимизации: генерация в Web Worker'е. + // Main thread не блокируется, UI отзывчив, progress-bar плавный. + const { getTerrainGenWorkerUrl } = await import('./TerrainGenWorker'); + const workerUrl = getTerrainGenWorkerUrl(); + const worker = new Worker(workerUrl); + + let voxels, decorations, treesPlaced, statsTimeMs; + try { + await new Promise((resolve, reject) => { + worker.onmessage = (e) => { + const m = e.data; + if (m.type === 'progress') { + onProgress(m.done, m.total, m.phase); + } else if (m.type === 'done') { + voxels = m.voxels; + decorations = m.decorations; + treesPlaced = m.treesPlaced; + statsTimeMs = m.timeMs; + resolve(); + } else if (m.type === 'error') { + reject(new Error('[Worker] ' + m.message)); + } + }; + worker.onerror = (err) => { + reject(new Error('[Worker] crash: ' + err.message)); + }; + worker.postMessage({ + type: 'generate', + params, + bbox: { x0: -size, z0: -size, x1: size, z1: size }, + }); + }); + } finally { + worker.terminate(); + URL.revokeObjectURL(workerUrl); + } + console.log(`[VoxelGen] generated ${voxels.length} voxels in ${statsTimeMs}ms (worker), ${treesPlaced} trees, ${decorations?.length || 0} decorations`); + onProgress(95, 100, 'render'); + await new Promise(r => setTimeout(r, 0)); + + // Заливаем в legacy TerrainManager (он отрендерит правильно). + // Очищаем TerrainMesh если был — на новых генерациях не нужен. + if (this._terrainMesh) { + try { this._terrainMesh.disposeAll(); } catch (e) {} + } + if (this.terrainManager) { + this.terrainManager.clear(); + if (this.terrainManager.loadFromArray.constructor.name === 'AsyncFunction') { + await this.terrainManager.loadFromArray(voxels); + } else { + this.terrainManager.loadFromArray(voxels); + } + console.log(`[VoxelGen] loaded into legacy TerrainManager`); + + // Также пишем в VoxelWorld для RLE-сжатия в БД + try { + const vwLayer = this.voxelWorld.getOrCreateLayer('terrain', 0.25); + vwLayer.clear(); + vwLayer.loadFromArray(voxels); + } catch (e) { /* ignore */ } + + // АВТОВКЛЮЧЕНИЕ STREAMING для больших карт. + const regionCount = this.terrainManager.getRegionCount?.() ?? 0; + if (regionCount > 0) { + this._terrainStreamingEnabled = true; + // Адаптивный radius по количеству вокселей: чем больше + // карта, тем меньше radius (иначе слишком много рендерится). + // <300K voxels → 40м (норма для маленьких карт) + // 300K-1M → 36м + // 1M-2M → 32м + // >2M → 28м (очень большие) + const vc = voxels.length; + let radius = 40; + if (vc > 2_000_000) radius = 28; + else if (vc > 1_000_000) radius = 32; + else if (vc > 300_000) radius = 36; + this._terrainStreamingRadius = radius; + this._terrainStreamingLastUpdate = 0; + const cam = this.camera; + if (cam) { + const r = this.terrainManager.updateStreaming(cam.position.x, cam.position.z, this._terrainStreamingRadius); + console.log(`[VoxelGen] streaming ON: radius=${this._terrainStreamingRadius}m (${vc} voxels), ${r.enabled}/${r.total} regions enabled`); + } + } else { + this._terrainStreamingEnabled = false; + } + } + + // Этап 6: загружаем decorations (мини-воксельные цветы/грибы). + if (this.decoManager && decorations) { + this.decoManager.loadFromArray(decorations); + // Этап D: первый pass LOD streaming для деко. + // maxBuild=2 — деко достроятся плавно через updateStreaming. + if (this.camera && this.decoManager.updateStreaming) { + const decoRadius = Math.max(18, (this._terrainStreamingRadius || 40) * 0.35); + this.decoManager.updateStreaming( + this.camera.position.x, this.camera.position.z, decoRadius, + { maxBuild: 2 }, + ); + } + } + + onProgress(100, 100, 'done'); + return { voxels: voxels.length, treesPlaced, decorations: decorations?.length || 0, timeMs: statsTimeMs }; + } finally { + // Снять глобальный лок ОБЯЗАТЕЛЬНО. + window.__voxelGenLock = false; + } + }; + // Готовые пресеты для быстрого теста + window.__voxelPresets = { + default: DEFAULT_GENERATOR_PARAMS, + mountains: { + ...DEFAULT_GENERATOR_PARAMS, + heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 50, exponent: 2.0 }, + }, + flat: { + ...DEFAULT_GENERATOR_PARAMS, + heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 3, exponent: 1.0 }, + }, + islands: { + ...DEFAULT_GENERATOR_PARAMS, + heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 15, exponent: 2.5 }, + }, + forest: { + ...DEFAULT_GENERATOR_PARAMS, + biomes: DEFAULT_GENERATOR_PARAMS.biomes.map(b => + b.id === 'plain' || b.id === 'forest' + ? { ...b, features: { ...b.features, trees: 1.5 } } + : b, + ), + }, + }; + + // Этап 4: streaming контроль + window.__voxelWorldStreaming = (enabled, radius = 64) => { + this._voxelStreamingEnabled = !!enabled; + this._voxelStreamingRadius = radius; + if (!this._voxelRenderEnabled) { + console.log('[VoxelWorld] streaming on, но render выключен. Включи render: window.__voxelWorldRender(true)'); + return; + } + if (!this._voxelStreamingEnabled) { + // Сброс: загрузить все чанки обратно + this.voxelRenderer.rebuildAll(); + console.log('[VoxelWorld] streaming OFF — все чанки видимы'); + return; + } + // Стартовый update вокруг камеры + const cam = this.camera; + const center = { x: cam.position.x, z: cam.position.z }; + const r = this.voxelRenderer.updateStreaming(center, radius); + console.log(`[VoxelWorld] streaming ON, radius=${radius}m: ${r.loaded} loaded, ${r.unloaded} unloaded, ${this.voxelRenderer.getActiveChunkCount()}/${r.total} active`); + }; + // Этап 3 benchmark: сравнить размер legacy JSON vs RLE+base64 + window.__voxelWorldBenchmarkRLE = () => { + const t0 = performance.now(); + const rleData = this.voxelWorld.serialize(); + const t1 = performance.now(); + const rleJson = JSON.stringify(rleData); + const t2 = performance.now(); + const rleBytes = new Blob([rleJson]).size; + + // Legacy формат для сравнения — массив {x,y,z,m} + const legacyVoxels = []; + const layer = this.voxelWorld.getLayer('terrain'); + if (layer) { + for (const ch of layer.chunks.values()) { + const ox = ch.voxelOriginX(); + const oy = ch.voxelOriginY(); + const oz = ch.voxelOriginZ(); + for (let i = 0; i < 32768; i++) { + const idx = ch.data[i]; + if (idx === 0) continue; + const m = layer.matIdxToId(idx); + const lx = i % 32; + const lz = ((i / 32) | 0) % 32; + const ly = (i / 1024) | 0; + legacyVoxels.push({ x: ox + lx, y: oy + ly, z: oz + lz, m }); + } + } + } + const t3 = performance.now(); + const legacyJson = JSON.stringify(legacyVoxels); + const legacyBytes = new Blob([legacyJson]).size; + const t4 = performance.now(); + + const ratio = (legacyBytes / rleBytes).toFixed(1); + const sizes = this.voxelWorld.measureSize(); + console.log('[RLE Benchmark]'); + console.log(` Legacy JSON: ${(legacyBytes / 1024).toFixed(0)} KB (serialize: ${(t4 - t2).toFixed(0)} ms)`); + console.log(` RLE+base64: ${(rleBytes / 1024).toFixed(0)} KB (serialize: ${(t2 - t0).toFixed(0)} ms)`); + console.log(` Уменьшение: ${ratio}× меньше`); + console.log(' Подробно:', sizes); + return { legacy: legacyBytes, rle: rleBytes, ratio }; + }; + + // Чистый benchmark mesh-build без создания Babylon-meshей. Это + // показывает скорость алгоритма greedy в отрыве от GPU. + window.__voxelWorldBenchmark = async () => { + const { buildChunkGeometryGreedy } = await import('./voxel/GreedyMesher'); + const { buildChunkGeometry } = await import('./voxel/ChunkMesher'); + const layer = this.voxelWorld.getLayer('terrain'); + if (!layer) { console.warn('no terrain layer'); return; } + const neighborMatIdx = (gx, gy, gz) => layer.getMatIdx(gx, gy, gz); + + // Surface culling (Этап 1) + let totalFacesNonGreedy = 0; + const t1 = performance.now(); + for (const ch of layer.chunks.values()) { + const r = buildChunkGeometry(ch, layer, neighborMatIdx); + totalFacesNonGreedy += r.totalFaces; + } + const dt1 = performance.now() - t1; + + // Greedy (Этап 2) + let totalFacesGreedy = 0; + const t2 = performance.now(); + for (const ch of layer.chunks.values()) { + const r = buildChunkGeometryGreedy(ch, layer, neighborMatIdx); + totalFacesGreedy += r.totalFaces; + } + const dt2 = performance.now() - t2; + + const reduction = ((1 - totalFacesGreedy / totalFacesNonGreedy) * 100).toFixed(1); + console.log(`[Benchmark] Surface culling: ${totalFacesNonGreedy} quads in ${dt1.toFixed(0)}ms`); + console.log(`[Benchmark] Greedy meshing: ${totalFacesGreedy} quads in ${dt2.toFixed(0)}ms — на ${reduction}% меньше квадров`); + return { surfaceCulling: { quads: totalFacesNonGreedy, ms: dt1 }, + greedy: { quads: totalFacesGreedy, ms: dt2 }, + reduction: `${reduction}%` }; + }; + window.__voxelWorldRender = (enabled) => { + this._voxelRenderEnabled = !!enabled; + if (this._voxelRenderEnabled) { + // Прячем legacy TerrainManager mesh'и + for (const proto of this.terrainManager._protoMeshes?.values?.() ?? []) { + proto.setEnabled(false); + } + // Если streaming ON — грузим только видимые чанки + // (rebuildAll потом бы их сразу half-выгрузил, лишняя работа). + if (this._voxelStreamingEnabled && this.camera) { + const cam = this.camera; + const r = this.voxelRenderer.updateStreaming( + { x: cam.position.x, z: cam.position.z }, + this._voxelStreamingRadius, + ); + console.log(`[VoxelWorld] render ENABLED (streaming): ${r.loaded} loaded, ${this.voxelRenderer.getActiveChunkCount()}/${r.total} active`); + } else { + this.voxelRenderer.rebuildAll(); + console.log('[VoxelWorld] render ENABLED, legacy hidden'); + } + } else { + // Показываем legacy обратно, скрываем новый + for (const proto of this.terrainManager._protoMeshes?.values?.() ?? []) { + proto.setEnabled(true); + } + this.voxelRenderer.dispose(); + console.log('[VoxelWorld] render DISABLED, legacy restored'); + } + }; + } + // Состояние brush'а ландшафта, обновляется из TerrainPanel. + // tool — 'select'|'transform'|'fill'|'sealevel'|'draw'|'sculpt'|'smooth'|'paint'|'flatten' + // material — id из TERRAIN_MATERIALS + // brushSize — радиус кисти в voxel'ах + // strength — 1..100 + // shape — 'sphere'|'cube'|'cylinder' + this._terrainBrush = { + tool: 'sculpt', + material: 'grass', + brushSize: 4, + strength: 50, + shape: 'sphere', + // terrainMode: 'voxel' (по умолчанию) | 'smooth'. + // В smooth-режиме кисти редактируют DensityGrid через SmoothBrushes, + // в voxel — TerrainManager.voxels (как раньше). + terrainMode: 'voxel', + }; + // Полупрозрачный preview-меш под курсором (показывает где будет кисть) + this._terrainBrushPreview = null; + this.modelManager = new ModelManager(this.scene, this); + this.modelManager.setScene3D(this); + // Делаем ModelManager доступным через scene — MultiplayerSync.js + // подхватывает его для shared-кэша GLB-прототипов. + this.scene._kubikonModelManager = this.modelManager; + + // Этап 5 редактора моделей: менеджер пользовательских voxel-моделей. + // API подключается отдельно через setUserModelsApi (см. ниже), + // потому что Kubikon3DService импортируется через ES-modules. + this.userModelManager = new UserModelManager(this.scene); + // Глобальная функция для отладки: window.__kubikonDebugColliders() + // выводит в консоль все коллайдеры моделей и примитивов. + if (typeof window !== 'undefined') { + window.__kubikonDebugColliders = () => { + const out = []; + if (this.modelManager) { + for (const data of this.modelManager.instances.values()) { + const a = data.localAABB; + if (a) { + const w = (a.maxX - a.minX).toFixed(2); + const h = (a.maxY - a.minY).toFixed(2); + const d = (a.maxZ - a.minZ).toFixed(2); + out.push({ + kind: 'model', id: data.modelTypeId, + pos: [data.x.toFixed(1), data.y.toFixed(1), data.z.toFixed(1)], + size: [w, h, d], + canCollide: data.canCollide, + }); + } + } + } + if (this.primitiveManager) { + for (const data of this.primitiveManager.instances.values()) { + out.push({ + kind: 'primitive', type: data.type, + pos: [data.x.toFixed(1), data.y.toFixed(1), data.z.toFixed(1)], + size: [data.sx, data.sy, data.sz], + canCollide: data.canCollide, + }); + } + } + console.table(out); + return out; + }; + } + this.primitiveManager = new PrimitiveManager(this.scene); + // Ссылка на обёртку BabylonScene — нужна для эмиттеров частиц + // (createEmitterParticles живёт на обёртке). + this.primitiveManager.scene3d = this; + // BillboardUiManager — отдельный модуль, рисует GUI на DynamicTexture + // для билбордов. Нужен PrimitiveManager-у чтобы при создании billboard + // (type='billboard') сразу применить текстуру с дефолтным пресетом. + this.billboardUiManager = new BillboardUiManager(this.scene); + this.primitiveManager.billboardUiManager = this.billboardUiManager; + this.folderManager = new FolderManager(this.blockManager, this.modelManager, this.primitiveManager); + this.guiManager = new GuiManager(); + this.modalManager = new ModalManager(); + this.modalManager.attachScene(this); + this.modalManager.attachGui(this.guiManager); + this.inventory = new InventoryManager(); + this.physics = new PhysicsAABB(this.blockManager); + // Сразу синхронизируем границу пола с текущим размером мира, + // иначе при дефолтных 40 игрок проваливается на больших картах + // ещё до первого setWorldSize(). + this.physics.floorHalf = this._worldHalf; + this.physics.setPrimitiveManager(this.primitiveManager); + this.physics.setModelManager(this.modelManager); + this.physics.setUserModelManager(this.userModelManager); + // Voxel-террейн тоже участвует в физике. У террейна свой размер + // ячейки (TERRAIN_VOXEL_SIZE = 0.5), поэтому передаём отдельно. + this.physics.setTerrainManager(this.terrainManager, TERRAIN_VOXEL_SIZE); + // Этап 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); + + // 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 — Screen-Space Ambient Occlusion (контактные тени в углах, + // под объектами и в стыках). По умолчанию выключен — это дорогой + // пост-эффект (-15..30% FPS). Включается через setSsaoEnabled(true) / + // setLightingProps({ ssaoEnabled: true }) из инспектора Lighting. + this._ssaoPipeline = null; + this._ssaoEnabled = false; + } + + /** + * Включить/выключить SSAO пост-эффект (контактные тени). + * Используем SSAORenderingPipeline v1 — v2 ломал thin-instance рендер + * (блоки/террейн пропадали) из-за GeometryBufferRenderer. v1 использует + * только depthRenderer и совместим со всеми мешами. + */ + setSsaoEnabled(on) { + const want = !!on; + if (this._ssaoEnabled === want && (!want || this._ssaoPipeline)) return; + if (!want) { + this._disposeSsaoPipeline(); + this._ssaoEnabled = false; + return; + } + if (!this.scene.activeCamera) { + console.warn('[BabylonScene] SSAO: нет активной камеры'); + return; + } + try { + const ratio = { ssaoRatio: 0.5, combineRatio: 1.0 }; + const pipeline = new SSAORenderingPipeline( + 'ssaopipeline', this.scene, ratio, [this.scene.activeCamera] + ); + pipeline.fallOff = 0.000001; + pipeline.area = 0.0075; + pipeline.radius = 0.0001; + pipeline.totalStrength = 1.0; + pipeline.base = 0.5; + this._ssaoPipeline = pipeline; + this._ssaoEnabled = true; + } catch (e) { + console.warn('[BabylonScene] SSAO не запустился:', e?.message || e); + this._disposeSsaoPipeline(); + this._ssaoEnabled = false; + } + } + + /** Полностью убрать SSAO пайплайн (detach + remove + dispose). */ + _disposeSsaoPipeline() { + if (!this._ssaoPipeline) return; + const mgr = this.scene.postProcessRenderPipelineManager; + const name = this._ssaoPipeline.name || 'ssaopipeline'; + try { + if (mgr && this.scene.activeCamera) { + mgr.detachCamerasFromRenderPipeline?.(name, this.scene.activeCamera); + } + } catch (e) { /* ignore */ } + try { + if (mgr && typeof mgr.removePipeline === 'function') { + mgr.removePipeline(name); + } + } catch (e) { /* ignore */ } + try { this._ssaoPipeline.dispose(); } catch (e) { /* ignore */ } + this._ssaoPipeline = null; + } + + getSsaoEnabled() { return this._ssaoEnabled; } + + /** Создаёт ShadowGenerator (если ещё нет) и применяет текущее качество. + * + * Поддерживаемые уровни (Этап 2 теней, 2026-05-27): + * - 'off' — теней нет + * - 'hard' — резкие тени, 512px, без блюра + * - 'soft' — мягкие тени, 1024px (на mobile 512), blurKernel 24 + * - 'medium' — CSM 1024 × 3 каскада, для среднего ПК + * - 'high' — CSM 2048 × 4 каскада, дорогой, для топовых ПК + */ + _ensureShadowGenerator() { + const q = this._shadowQuality; + if (q === 'off') { + if (this._shadowGenerator) { + try { this._shadowGenerator.dispose(); } catch (e) { /* ignore */ } + this._shadowGenerator = null; + } + return null; + } + // Если уже создан — но качество поменялось на тот тип где нужен другой + // движок (CSM vs обычный) — пересоздадим. + const wantCsm = (q === 'medium' || q === 'high'); + const haveCsm = this._shadowGenerator instanceof CascadedShadowGenerator; + if (this._shadowGenerator && wantCsm !== haveCsm) { + try { this._shadowGenerator.dispose(); } catch (e) { /* ignore */ } + this._shadowGenerator = null; + } + + // PCF = Percentage Closer Filtering. Правильная техника мягких теней. + // + // bias 0.0005, normalBias 0.005. Раньше normalBias=0.012 давал + // peter-panning — тень "уезжала" далеко в сторону от блока (баг + // 2026-05-27). 0.005 — баланс между acne и peter-panning для + // воксельных кубов 1м. + const PCF_BIAS = 0.0005; + const PCF_NORMAL_BIAS = 0.005; + + if (!this._shadowGenerator) { + if (wantCsm) { + // CSM с PCF. Поднял разрешение каскадов (2048/4096 — было 1024/2048). + const size = (q === 'high') ? 4096 : 2048; + const numCascades = (q === 'high') ? 4 : 3; + const csm = new CascadedShadowGenerator(size, this._sunLight); + csm.numCascades = numCascades; + csm.stabilizeCascades = true; + csm.lambda = 0.8; + 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 (было 1024). + let shadowSize; + if (q === 'hard') { + shadowSize = this._isMobileMode ? 512 : 1024; + } else { // soft + shadowSize = this._isMobileMode ? 1024 : 2048; + } + const gen = new ShadowGenerator(shadowSize, this._sunLight); + gen.bias = PCF_BIAS; + gen.normalBias = PCF_NORMAL_BIAS; + if (gen.getShadowMap) { + const rtt = gen.getShadowMap(); + if (rtt) rtt.refreshRate = 2; + } + this._shadowGenerator = gen; + } + } + + const gen = this._shadowGenerator; + if (q === 'medium' || q === 'high') { + // параметры CSM выставлены при создании + } else if (q === 'soft') { + gen.usePercentageCloserFiltering = true; + gen.filteringQuality = ShadowGenerator.QUALITY_MEDIUM; + gen.useBlurExponentialShadowMap = false; + gen.useKernelBlur = false; + gen.usePoissonSampling = false; + gen.darkness = 0.4; + } else { // hard + gen.usePercentageCloserFiltering = false; + gen.useBlurExponentialShadowMap = false; + gen.useKernelBlur = false; + gen.usePoissonSampling = false; + gen.darkness = 0.55; + } + return gen; + } + + /** + * Изменить качество теней. 'off' уничтожает генератор; 'hard'/'soft'/ + * 'medium'/'high' создают/обновляют. CSM используется для medium/high. + */ + setShadowQuality(q) { + const allowed = ['off', 'hard', 'soft', 'medium', 'high']; + if (!allowed.includes(q)) return; + this._shadowQuality = q; + this._ensureShadowGenerator(); + // Если выключили — снимем receiveShadows с пола (необязательно, но чище) + const ground = this.scene.getMeshByName('editorGround'); + if (ground) ground.receiveShadows = q !== 'off'; + // После смены качества — заново зарегистрировать всех casters + // (при пересоздании генератора список обнулился). + if (q !== 'off') { + try { this.refreshAllShadows(); } catch (e) { /* ignore */ } + } + } + + getShadowQuality() { return this._shadowQuality || 'soft'; } + + /** + * Установить свойства глобального освещения. Вызывается из Inspector + * (selection.type === 'lighting'). + * patch: { sunIntensity?, hemiIntensity?, hemiGround?, fogEnabled?, + * fogDensity?, fogColor?, shadowQuality? } + */ + setLightingProps(patch) { + if (!patch) return; + // Время суток — пресет / минуты день/ночь + if (patch.envPreset && this.environment) { + try { this.environment.setPreset(patch.envPreset); } catch (e) { /* ignore */ } + } + if (typeof patch.dayDurationMin === 'number' && patch.dayDurationMin > 0 && this.environment) { + this.environment.setCycleDuration(patch.dayDurationMin, this.environment.nightDurationMin); + } + if (typeof patch.nightDurationMin === 'number' && patch.nightDurationMin > 0 && this.environment) { + this.environment.setCycleDuration(this.environment.dayDurationMin, patch.nightDurationMin); + } + if (typeof patch.sunIntensity === 'number' && this._sunLight) { + this._sunLight.intensity = Math.max(0, patch.sunIntensity); + } + if (typeof patch.hemiIntensity === 'number' && this._hemiLight) { + this._hemiLight.intensity = Math.max(0, patch.hemiIntensity); + } + if (this.environment && typeof this.environment.setFog === 'function') { + // Текущие значения берём из Environment, поверх накладываем patch + const enabled = (typeof patch.fogEnabled === 'boolean') + ? patch.fogEnabled : this.environment.fogEnabled; + let color = this.environment.fogColor; + if (patch.fogColor && /^#[0-9a-fA-F]{6}$/.test(patch.fogColor)) { + color = [ + parseInt(patch.fogColor.substr(1, 2), 16) / 255, + parseInt(patch.fogColor.substr(3, 2), 16) / 255, + parseInt(patch.fogColor.substr(5, 2), 16) / 255, + ]; + } + const density = (typeof patch.fogDensity === 'number') + ? patch.fogDensity : this.environment.fogDensity; + if ('fogEnabled' in patch || 'fogDensity' in patch || 'fogColor' in patch) { + this.environment.setFog(enabled, color, density); + } + } + if (patch.shadowQuality) { + this.setShadowQuality(patch.shadowQuality); + this.refreshAllShadows(); + } + if (typeof patch.ssaoEnabled === 'boolean') { + this.setSsaoEnabled(patch.ssaoEnabled); + } + // Обновить selection чтобы Inspector сразу показывал новые значения + if (this.selection?._selection?.type === 'lighting') { + this.selection.selectLighting(); + } + } + + /** + * Сгруппировать текущие выделенные объекты в новую папку (Ctrl+G). + * Если выделен один — кладёт его одного. Если ничего — no-op. + */ + groupSelected(name = null) { + if (!this.folderManager || !this.selection) return null; + const multi = this.selection.getMultiSelection(); + const items = []; + if (multi.length > 0) { + for (const it of multi) items.push(it); + } else { + const s = this.selection.getSelection(); + if (!s) return null; + if (s.type === 'block') items.push({ kind: 'block', ref: { x: s.gridX, y: s.gridY, z: s.gridZ } }); + else if (s.type === 'model') items.push({ kind: 'model', ref: s.instanceId }); + else if (s.type === 'primitive') items.push({ kind: 'primitive', ref: s.id }); + else return null; + } + if (items.length === 0) return null; + const folderName = name || `Группа ${this.folderManager.getAll().length + 1}`; + const folderId = this.folderManager.createFolder(folderName, null); + for (const it of items) { + this.folderManager.assignToFolder(it.kind, it.ref, folderId); + } + return folderId; + } + + /** + * Зарегистрировать меш как «отбрасывающий тень». Безопасно вызывать многократно. + * ВАЖНО: только настоящие Mesh (с геометрией), а не TransformNode-узлы. + * ShadowGenerator вызывает getBoundingInfo()/getVerticesData() — у TransformNode + * этих методов нет, что приводит к runtime-крашу. + */ + /** Удалено: пытались через ShadowGenerator, не сработало. + * Теперь тени делает GdGroundSkin через синтетические «тени-кружки». */ + _enableGdShadows() { /* no-op */ } + + addShadowCaster(mesh) { + if (!this._shadowGenerator || !mesh) return; + // TransformNode не имеет getBoundingInfo/getVerticesData + if (typeof mesh.getBoundingInfo !== 'function') return; + if (typeof mesh.getTotalVertices !== 'function') return; + if (mesh.getTotalVertices() <= 0) return; + try { this._shadowGenerator.addShadowCaster(mesh, false); } catch (e) { /* ignore */ } + } + + /** + * Зарегистрировать все текущие блоки/модели/примитивы как shadow casters. + * Полезно вызвать после loadFromState или смены качества теней. + */ + refreshAllShadows() { + if (!this._shadowGenerator) return; + if (this.blockManager) { + // Для thin-instance proto-мешей: один addShadowCaster на тип = все инстансы + if (this.blockManager._protoMeshes) { + for (const proto of this.blockManager._protoMeshes.values()) { + this.addShadowCaster(proto); + } + } + // Жидкости/legacy mesh + for (const m of this.blockManager.blocks.values()) { + if (m && typeof m.getBoundingInfo === 'function') this.addShadowCaster(m); + } + } + if (this.modelManager) { + for (const inst of this.modelManager.instances.values()) { + const root = inst.rootMesh; + if (!root) continue; + // root обычно TransformNode → пропускаем сам root, добавляем только child-mesh'ы + if (typeof root.getChildMeshes === 'function') { + for (const cm of root.getChildMeshes()) this.addShadowCaster(cm); + } + } + } + if (this.primitiveManager) { + for (const inst of this.primitiveManager.instances.values()) { + if (inst.mesh) this.addShadowCaster(inst.mesh); + } + } + } + + _createGroundGrid() { + // Размер мира — настраивается через setWorldSize(). Пол идёт от -WORLD_HALF до +WORLD_HALF. + const WORLD_HALF = this._worldHalf; + + const ground = MeshBuilder.CreateGround( + 'editorGround', + { width: WORLD_HALF * 2, height: WORLD_HALF * 2, subdivisions: 1 }, + this.scene + ); + + // Baseplate-текстура (как в Roblox Studio): процедурный клетчатый паттерн. + // Рисуем в DynamicTexture — каждая «плитка» 1×1 на грани соответствует + // 1 единице мира. Делаем 64×64 пикселей — каждый пиксель = 1 квадрат. + const TEX_SIZE = 64; + const baseTex = new DynamicTexture('baseplateTex', { width: TEX_SIZE, height: TEX_SIZE }, this.scene, false); + baseTex.wrapU = 1; // wrap + baseTex.wrapV = 1; + baseTex.uScale = WORLD_HALF * 2 / 4; // одна повторка покрывает 4 клетки + baseTex.vScale = WORLD_HALF * 2 / 4; + baseTex.updateSamplingMode(DynamicTexture.NEAREST_SAMPLINGMODE); + const ctx = baseTex.getContext(); + // Серая основа + ctx.fillStyle = '#7a8071'; + ctx.fillRect(0, 0, TEX_SIZE, TEX_SIZE); + // Тёмные «рейки» по периметру плитки (Roblox-style) + ctx.strokeStyle = '#5d6358'; + ctx.lineWidth = 4; + ctx.strokeRect(2, 2, TEX_SIZE - 4, TEX_SIZE - 4); + // Тонкая внутренняя сетка 4 на плитке + ctx.strokeStyle = '#6c7268'; + ctx.lineWidth = 1; + for (let i = 1; i < 4; i++) { + const p = (i * TEX_SIZE) / 4; + ctx.beginPath(); + ctx.moveTo(p, 2); ctx.lineTo(p, TEX_SIZE - 2); + ctx.moveTo(2, p); ctx.lineTo(TEX_SIZE - 2, p); + ctx.stroke(); + } + baseTex.update(); + + const mat = new StandardMaterial('groundMat', this.scene); + mat.diffuseTexture = baseTex; + mat.specularColor = new Color3(0, 0, 0); + ground.material = mat; + ground.receiveShadows = true; + + // Только две осевые линии (X и Z), цветные — для ориентации в редакторе. + // Сетку делает сама baseplate-текстура. + const axisMatX = new StandardMaterial('axisMatX', this.scene); + axisMatX.diffuseColor = new Color3(0.85, 0.25, 0.25); + axisMatX.emissiveColor = new Color3(0.5, 0.1, 0.1); + axisMatX.specularColor = new Color3(0, 0, 0); + + const axisMatZ = new StandardMaterial('axisMatZ', this.scene); + axisMatZ.diffuseColor = new Color3(0.25, 0.4, 0.85); + axisMatZ.emissiveColor = new Color3(0.1, 0.2, 0.5); + axisMatZ.specularColor = new Color3(0, 0, 0); + + // Ось X (красная) — линия вдоль X на z=0 + const axisX = MeshBuilder.CreateBox('axisX', + { width: WORLD_HALF * 2, height: 0.02, depth: 0.1 }, this.scene); + axisX.position = new Vector3(0, 0.011, 0); + axisX.material = axisMatX; + axisX.isPickable = false; + this._gridLines = [axisX]; + + // Ось Z (синяя) — линия вдоль Z на x=0 + const axisZ = MeshBuilder.CreateBox('axisZ', + { width: 0.1, height: 0.02, depth: WORLD_HALF * 2 }, this.scene); + axisZ.position = new Vector3(0, 0.011, 0); + axisZ.material = axisMatZ; + axisZ.isPickable = false; + this._gridLines.push(axisZ); + } + + /** + * Изменить размер пола (worldSize × worldSize). Пересоздаёт пол и осевые линии. + * @param {number} worldSize — полный размер стороны пола в юнитах (например 100, 200, 500). + */ + setWorldSize(worldSize) { + const half = Math.max(10, Math.round(worldSize / 2)); + if (half === this._worldHalf) return; + this._worldHalf = half; + // ВАЖНО: physics.floorHalf по умолчанию 40. Если визуальная плита + // больше — игрок проваливается за пределами центрального 80×80 + // квадрата. Синхронизируем физику с визуалом. + if (this.physics) this.physics.floorHalf = half; + // Удалить старый пол + осевые линии + const oldGround = this.scene.getMeshByName('editorGround'); + if (oldGround) try { oldGround.dispose(); } catch (e) { /* ignore */ } + if (Array.isArray(this._gridLines)) { + for (const line of this._gridLines) { + try { line.dispose(); } catch (e) { /* ignore */ } + } + } + this._gridLines = []; + this._createGroundGrid(); + } + + /** Текущий размер пола в юнитах (worldSize, не worldHalf). */ + getWorldSize() { return this._worldHalf * 2; } + + /** Включить/выключить пол (визуально и физически). */ + setFloorEnabled(enabled) { + this._floorEnabled = !!enabled; + if (!this.scene) return; + const ground = this.scene.getMeshByName('editorGround'); + if (ground) ground.setEnabled(this._floorEnabled); + // Линии осей тоже + if (Array.isArray(this._gridLines)) { + for (const line of this._gridLines) { + if (line && line.setEnabled) line.setEnabled(this._floorEnabled); + } + } + // Физика: отключаем коллизию с baseplate, чтобы игрок проваливался + if (this.physics) this.physics.floorEnabled = this._floorEnabled; + } + isFloorEnabled() { return this._floorEnabled !== false; } + + /** + * Очистить гладкий ландшафт (RobloxTerrain) — убирает все chunks, + * отвязывает от физики, возвращает baseplate-пол, ставит spawn по умолчанию. + * Вызывается из UI (кнопка «✖» в Генератор-панели). + */ + clearRobloxTerrain() { + let hadTerrain = false; + if (this._robloxTerrain) { + try { this._robloxTerrain.disposeAll(); hadTerrain = true; } catch (e) {} + // ВАЖНО: обнуляем ссылку, иначе __robloxTest при новой генерации + // решит что terrain уже есть и НЕ переподключит его к physics. + this._robloxTerrain = null; + } + // Декорации тоже чистим (thin-instances очищаются, prototype остаётся + // в памяти для следующего применения). + if (this._smoothDecoManager) { + try { this._smoothDecoManager.clear(); } catch (e) {} + } + this._smoothDecoParams = null; + if (this.physics?.setRobloxTerrain) { + this.physics.setRobloxTerrain(null); + } + if (this.physics?.setSmoothDecoTrees) { + this.physics.setSmoothDecoTrees(null); + } + try { this.setFloorEnabled(true); } catch (e) {} + this._spawnPoint = { x: 0, y: 5, z: 0 }; + try { this._updateSpawnMarker?.(); } catch (e) {} + // Прячем мини-карту гладкого ландшафта — grid больше нет. + if (window.__robloxMinimapGrid) { + window.__robloxMinimapGrid = null; + this._terrainStreamingEnabled = false; + } + // Помечаем dirty чтобы автосейв записал пустой robloxTerrain + try { this._onSceneChange?.(); } catch (e) {} + console.log(`[BabylonScene] clearRobloxTerrain (hadTerrain=${hadTerrain})`); + } + + /** + * Множитель силы прыжка игрока. 1 = базовый (~8 у/с), 1.5 = в 1.5 раза выше. + * Применяется при enterPlayMode и через player.setJumpPower. + */ + setPlayerJumpPower(mul) { + const m = Math.max(0.2, Math.min(5, Number(mul) || 1)); + this._jumpPowerMul = m; + if (this.player) this.player._jumpPowerMul = m; + } + getPlayerJumpPower() { return this._jumpPowerMul ?? 1; } + + /** Тип прицела в Play: 'none' | 'dot' | 'cross' | 'circle'. */ + setCrosshair(type) { + const allowed = ['none', 'dot', 'cross', 'circle']; + if (!allowed.includes(type)) return; + this._crosshair = type; + } + getCrosshair() { return this._crosshair || 'none'; } + + /** + * LOD/culling для моделей: модели дальше LOD_FREEZE замораживают мировую + * матрицу (экономия CPU), модели дальше LOD_CULL — отключаются от рендера. + * Запускается в render-loop из tick(). + */ + _updateModelLOD() { + if (!this.modelManager || !this.camera) return; + const cam = this.camera.position; + const LOD_FREEZE = 60; // юниты — за этим расстоянием freezeWorldMatrix + const LOD_FREEZE_SQ = LOD_FREEZE * LOD_FREEZE; + const LOD_CULL = 600; // юниты — за этим расстоянием полностью скрываем + // (было 200; увеличено чтобы модели на удалённых уровнях не пропадали при editor-камере) + const LOD_CULL_SQ = LOD_CULL * LOD_CULL; + for (const data of this.modelManager.instances.values()) { + const root = data.rootMesh; + if (!root) continue; + // Динамические объекты (зомби, спавнеры, runtime-спавнутые) НЕ + // подвергаем LOD-freeze — за ними двигает свой менеджер. + const gp = data.gameplay; + if (gp?.isZombie || gp?.isZombieSpawner || data._spawnedAtRuntime) continue; + const dx = root.position.x - cam.x; + const dy = root.position.y - cam.y; + const dz = root.position.z - cam.z; + const distSq = dx * dx + dy * dy + dz * dz; + // Cull + const shouldCull = distSq > LOD_CULL_SQ && data.visible !== false; + if (shouldCull && root._kubikonCulled !== true) { + root.setEnabled(false); + root._kubikonCulled = true; + } else if (!shouldCull && root._kubikonCulled === true) { + root.setEnabled(data.visible !== false); + root._kubikonCulled = false; + } + // Freeze + const shouldFreeze = distSq > LOD_FREEZE_SQ; + if (shouldFreeze && root._kubikonFrozen !== true) { + try { root.freezeWorldMatrix(); } catch (e) { /* ignore */ } + root._kubikonFrozen = true; + } else if (!shouldFreeze && root._kubikonFrozen === true) { + try { root.unfreezeWorldMatrix(); } catch (e) { /* ignore */ } + root._kubikonFrozen = false; + } + } + } + + /** + * LOD-cull для примитивов: далёкие декорации скрываем, ближние видны. + * Только в Play-режиме (в редакторе пользователь должен видеть всю сцену + * чтобы редактировать). На больших проектах (Only Up: 568 примитивов на + * вертикальной башне) это критично — без LOD при повороте камеры Babylon + * frustum-cull'ит сотни мешей и FPS падает в пол. + */ + _updatePrimitiveLOD() { + if (!this._isPlaying) return; + if (!this.primitiveManager || !this.camera) return; + const cam = this.camera.position; + const CULL = 120; + const CULL_SQ = CULL * CULL; + for (const data of this.primitiveManager.instances.values()) { + const m = data.mesh; + if (!m) continue; + // Не трогаем явно скрытые/невидимые скриптом + if (data.visible === false) continue; + const dx = data.x - cam.x; + const dy = data.y - cam.y; + const dz = data.z - cam.z; + const distSq = dx * dx + dy * dy + dz * dz; + const shouldCull = distSq > CULL_SQ; + if (shouldCull && m._kubikonPrimCulled !== true) { + m.setEnabled(false); + m._kubikonPrimCulled = true; + } else if (!shouldCull && m._kubikonPrimCulled === true) { + m.setEnabled(true); + m._kubikonPrimCulled = false; + } + } + } + + /** + * Roblox-style input handlers. + * Мышиные события — на canvas (только когда мышь над сценой). + * Клавиатурные — на window (работают при любом фокусе, как в реальных + * 3D-редакторах). Используем e.code (KeyW, KeyA, KeyS, KeyD, KeyQ, KeyE, KeyF) + * чтобы клавиши работали на любой раскладке (русская/английская). + */ + _setupInputControls() { + const canvas = this.canvas; + + // === МЫШЬ === + // mousedown на canvas в capture-фазе → срабатывает первым, + // даже если поверх есть другие listeners. + const onMouseDown = (e) => { + if (this._isPlaying) { + // В Play-режиме ЛКМ — клик игрока в forward-направлении. + // При pointer-lock курсор в центре; в third (свободный курсор) + // передаём реальные координаты клика для pick по табличкам. + if (this.placementManager && this.placementManager.isActive()) { + if (e.button === 0) { e.preventDefault(); this.placementManager.confirm(); return; } + if (e.button === 2) { e.preventDefault(); this.placementManager.cancel(); return; } + } + if (e.button === 0) { + const r = canvas.getBoundingClientRect(); + this._handlePlayClick(e.clientX - r.left, e.clientY - r.top); + } + return; + } + // Обновляем pointer координаты для raycast и Gizmo + const r = canvas.getBoundingClientRect(); + this.scene.pointerX = e.clientX - r.left; + this.scene.pointerY = e.clientY - r.top; + + // Если это ЛКМ — пробуем pickнуть гизмо. Если попали в гизмо — + // отдаём событие Babylon GizmoManager и выходим (не ставим блок). + // Проверка attachedMesh || attachedNode — у разных версий Babylon + // и при attachToMesh vs attachToNode заполняется разное поле. + const hasAttachment = this._gizmo && + (this._gizmo.manager.attachedMesh || this._gizmo.manager.attachedNode); + if (e.button === 0 && hasAttachment) { + const ulScene = this._gizmoLayer?.utilityLayerScene; + if (ulScene) { + const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY); + if (ulPick && ulPick.hit) { + // Симулируем pointer-events для GizmoManager на utility-scene + ulScene.simulatePointerDown(ulPick); + this._gizmoDragging = true; + e.preventDefault(); + return; + } + } + } + + // Запоминаем стартовую точку любого нажатия — для drag-detection. + this._mouseDownButton = e.button; + this._mouseDownX = e.clientX; + this._mouseDownY = e.clientY; + this._mouseDownTime = Date.now(); + + // ЛКМ + tool=block/erase → активируем drag-постановку. + // Сразу же ставим первый блок в клетке под курсором. + if (e.button === 0 && !e.shiftKey + && (this._activeTool === 'block' || this._activeTool === 'erase')) { + this._isDragPlacing = true; + this._lastPlacedKey = null; + this._dragLockAxis = null; + this._dragPlaceTick(false, /*isFirst*/ true); + e.preventDefault(); + } + // ЛКМ + tool=terrain → активируем drag-кисть террейна. + // Shift модификатор обрабатывается внутри _terrainBrushTick (стирание). + else if (e.button === 0 && this._activeTool === 'terrain') { + this._isTerrainBrushing = true; + this._terrainDragLockY = null; + this._terrainHistoryOpen(); // снапшот для undo + this._setTerrainBrushPreviewActive(true); + this._terrainBrushTick(e.shiftKey, /*isFirst*/ true); + e.preventDefault(); + } + // Shift+ЛКМ — drag-удаление (даже если tool=block) + else if (e.button === 0 && e.shiftKey) { + this._isDragPlacing = true; + this._lastPlacedKey = null; + this._dragLockAxis = null; + this._dragPlaceTick(true, /*isFirst*/ true); + e.preventDefault(); + } + + if (e.button === 2) { + this._isRotating = true; + this._lastMouseX = e.clientX; + this._lastMouseY = e.clientY; + canvas.style.cursor = 'grabbing'; + e.preventDefault(); + e.stopPropagation(); + } else if (e.button === 1) { + this._isPanning = true; + this._lastMouseX = e.clientX; + this._lastMouseY = e.clientY; + canvas.style.cursor = 'move'; + e.preventDefault(); + e.stopPropagation(); + } + // ЛКМ (button === 0) ничего не запускает сразу — обрабатывается на mouseup + // только если был "клик" (не drag). + }; + + // mouseup и mousemove — на window, чтобы drag работал даже когда + // курсор вышел за пределы canvas (стандартное поведение для drag). + const onMouseMove = (e) => { + // Babylon без detachControl сам не пишет в scene.pointerX/Y — + // делаем это руками. Нужны для raycast (scene.pick) и для гизмо. + const r = canvas.getBoundingClientRect(); + this.scene.pointerX = e.clientX - r.left; + this.scene.pointerY = e.clientY - r.top; + + // Если идёт drag гизмо — проксируем move в utility-scene + if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) { + const ulScene = this._gizmoLayer.utilityLayerScene; + const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY); + ulScene.simulatePointerMove(ulPick); + return; + } + + // Если идёт drag-постановка блоков — пытаемся поставить в новой клетке + if (this._isDragPlacing) { + this._dragPlaceTick(e.shiftKey); + return; + } + + // Если идёт drag-кисть террейна — продолжаем рисовать. + // Двигаем preview-меш под курсор ВНУТРИ drag тоже, иначе сфера + // зависает в точке первого клика, пока юзер водит мышью. + if (this._isTerrainBrushing) { + this._terrainBrushTick(e.shiftKey, /*isFirst*/ false); + this._updateTerrainBrushPosition(); + return; + } + + // Когда tool=terrain без drag'а — подвигаем preview-меш под курсор + if (this._activeTool === 'terrain') { + this._updateTerrainBrushPosition(); + } + + if (!this._isRotating && !this._isPanning) return; + const dx = e.clientX - this._lastMouseX; + const dy = e.clientY - this._lastMouseY; + this._lastMouseX = e.clientX; + this._lastMouseY = e.clientY; + + if (this._isRotating) { + this.camera.rotation.y += dx * this.ROTATE_SENSITIVITY; + this.camera.rotation.x += dy * this.ROTATE_SENSITIVITY; + const limit = Math.PI / 2 - 0.05; + if (this.camera.rotation.x > limit) this.camera.rotation.x = limit; + if (this.camera.rotation.x < -limit) this.camera.rotation.x = -limit; + } else if (this._isPanning) { + const right = this._getCameraRight(); + const up = this._getCameraUp(); + this.camera.position.addInPlace(right.scale(-dx * this.PAN_SENSITIVITY)); + this.camera.position.addInPlace(up.scale(dy * this.PAN_SENSITIVITY)); + } + }; + + const onMouseUp = (e) => { + // Если идёт drag гизмо — отдаём pointerup и завершаем + if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) { + const ulScene = this._gizmoLayer.utilityLayerScene; + const r = canvas.getBoundingClientRect(); + this.scene.pointerX = e.clientX - r.left; + this.scene.pointerY = e.clientY - r.top; + const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY); + ulScene.simulatePointerUp(ulPick); + this._gizmoDragging = false; + this._mouseDownButton = -1; + return; + } + // Если был drag-кисть террейна — сбрасываем флаг + if (this._isTerrainBrushing) { + this._isTerrainBrushing = false; + this._terrainDragLockY = null; + this._smoothBrushLockY = null; + this._smoothBrushLastPos = null; + this._terrainHistoryClose(); // фиксируем undo-снапшот + this._setTerrainBrushPreviewActive(false); + this._mouseDownButton = -1; + return; + } + // Если был drag-place — просто сбрасываем флаг, клик не обрабатываем + // (первая постановка уже сделана при mousedown). + if (this._isDragPlacing) { + this._isDragPlacing = false; + this._lastPlacedKey = null; + this._dragLockAxis = null; + this._mouseDownButton = -1; + return; + } + // Если это была ЛКМ и НЕ drag (курсор не сдвинулся существенно) + // → это клик; обрабатываем как редактор-клик (поставить/удалить блок). + if (e.button === 0 && this._mouseDownButton === 0) { + const dx = Math.abs(e.clientX - this._mouseDownX); + const dy = Math.abs(e.clientY - this._mouseDownY); + const dt = Date.now() - this._mouseDownTime; + if (dx < 4 && dy < 4 && dt < 400) { + this._handleEditorClick(e.shiftKey, e.ctrlKey || e.metaKey); + } + } + this._mouseDownButton = -1; + + if (e.button === 2) { + this._isRotating = false; + canvas.style.cursor = 'default'; + } else if (e.button === 1) { + this._isPanning = false; + canvas.style.cursor = 'default'; + } + }; + + const onWheel = (e) => { + e.preventDefault(); + if (this._isPlaying && this.placementManager && this.placementManager.isActive()) { + this.placementManager.rotate(); + return; + } + const forward = this._getCameraForward(); + const delta = -Math.sign(e.deltaY) * this.ZOOM_SPEED; + this.camera.position.addInPlace(forward.scale(delta)); + }; + + const onContextMenu = (e) => { + e.preventDefault(); + }; + + // === КЛАВИАТУРА === + // Используем e.code (KeyW, KeyA, ...) — независимо от раскладки. + // ВАЖНО: e.key на русской раскладке возвращает кириллицу ('ц', 'ы', ...), + // поэтому надёжно использовать только e.code. + + /** + * Игнорировать события клавиатуры если фокус в input/textarea/contenteditable. + * Иначе пробел/буквы из ввода в модалке двигают камеру и блокируют ввод. + * Также игнорируем когда открыта модалка (z-index overlay). + */ + const isTypingTarget = (target) => { + if (!target) return false; + const tag = (target.tagName || '').toLowerCase(); + if (tag === 'input' || tag === 'textarea' || tag === 'select') return true; + if (target.isContentEditable) return true; + return false; + }; + + const onKeyDown = (e) => { + if (isTypingTarget(e.target)) return; + this._codes.add(e.code); + if (e.shiftKey) this._shiftDown = true; + // Маршрутизация game.onKey в Play-режиме + if (this._isPlaying && this.gameRuntime) { + const key = this._normalizeKey(e); + this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code }); + } + if (this._isPlaying && this.placementManager && this.placementManager.isActive()) { + if (e.code === 'KeyR') { e.preventDefault(); this.placementManager.rotate(); return; } + if (e.code === 'Escape') { e.preventDefault(); this.placementManager.cancel(); return; } + } + if (e.code === 'KeyF') { + this._focusOnTarget(new Vector3(0, 0, 0)); + } + // Ctrl+D — дублировать выделенное + if (e.code === 'KeyD' && (e.ctrlKey || e.metaKey) && !this._isPlaying) { + e.preventDefault(); + this.duplicateSelected(); + return; + } + // Ctrl+C — копировать выделенное в буфер (Фаза 5.10). + if (e.code === 'KeyC' && (e.ctrlKey || e.metaKey) && !this._isPlaying) { + e.preventDefault(); + this.copySelected(); + return; + } + // Ctrl+V — вставить из буфера (работает и между проектами). + if (e.code === 'KeyV' && (e.ctrlKey || e.metaKey) && !this._isPlaying) { + e.preventDefault(); + this.pasteFromClipboard(); + return; + } + // Ctrl+Z — undo, Ctrl+Shift+Z или Ctrl+Y — redo + if ((e.ctrlKey || e.metaKey) && !this._isPlaying) { + if (e.code === 'KeyZ' && !e.shiftKey) { + e.preventDefault(); + this.undo?.(); + return; + } + if ((e.code === 'KeyZ' && e.shiftKey) || e.code === 'KeyY') { + e.preventDefault(); + this.redo?.(); + return; + } + // Ctrl+G — сгруппировать выделенное в новую папку + if (e.code === 'KeyG') { + e.preventDefault(); + this.groupSelected(); + return; + } + // Ctrl+A — выделить всё + if (e.code === 'KeyA') { + e.preventDefault(); + this.selection?.selectAll(); + return; + } + } + + // R — повернуть ghost-модель на 90° (или выделенную модель) + if (e.code === 'KeyR' && !this._isPlaying) { + const sel = this.selection?.getSelection(); + if (sel?.type === 'model') { + const newAngle = (sel.rotationY || 0) + Math.PI / 2; + this.selection.rotateSelectedModel(newAngle); + } else if (this._activeTool === 'model') { + this._ghostRotationY = (this._ghostRotationY + Math.PI / 2) % (Math.PI * 2); + } + } + // Delete / Backspace — удалить выделенный + if ((e.code === 'Delete' || e.code === 'Backspace') && !this._isPlaying) { + // Приоритет: выбранная инструментом «Выбрать деко» декорация. + if (this._decoSelection) { + this._deleteSelectedDeco(); + e.preventDefault(); + } else if (this.selection?.getSelection()) { + this.selection.deleteSelected(); + e.preventDefault(); + } + } + // Escape — снять выделение + переключиться на инструмент «Выделить» + // (в режиме игры Esc обрабатывает PlayerController — выход из Play). + if (e.code === 'Escape' && !this._isPlaying) { + this.selection?.clear(); + if (this._onEditorEscape) { + try { this._onEditorEscape(); } catch (err) { /* ignore */ } + } + } + if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) { + e.preventDefault(); + } + }; + + const onKeyUp = (e) => { + if (isTypingTarget(e.target)) return; + this._codes.delete(e.code); + if (!e.shiftKey) this._shiftDown = false; + if (this._isPlaying && this.gameRuntime) { + const key = this._normalizeKey(e); + this.gameRuntime.routeGlobalEvent('keyup', { key, code: e.code }); + } + }; + + const onBlur = () => { + this._codes.clear(); + this._shiftDown = false; + this._isRotating = false; + this._isPanning = false; + canvas.style.cursor = 'default'; + }; + + // Регистрация: + // - mousedown/move/up на CANVAS в capture-фазе. Это самое надёжное место + // для перехвата мыши над сценой; наш обработчик отрабатывает первым, + // до Babylon-овских стандартных listeners. + // - keydown/keyup — на window (клавиатуру всегда слушаем глобально). + // - wheel/contextmenu — на canvas в capture. + canvas.addEventListener('mousedown', onMouseDown, true); + canvas.addEventListener('wheel', onWheel, { passive: false, capture: true }); + canvas.addEventListener('contextmenu', onContextMenu, true); + // mousemove/mouseup на window — для drag за пределами canvas. + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + window.addEventListener('blur', onBlur); + + this._listeners = [ + { target: canvas, type: 'mousedown', fn: onMouseDown, opts: true }, + { target: canvas, type: 'wheel', fn: onWheel, opts: { capture: true } }, + { target: canvas, type: 'contextmenu', fn: onContextMenu, opts: true }, + { target: window, type: 'mousemove', fn: onMouseMove }, + { target: window, type: 'mouseup', fn: onMouseUp }, + { target: window, type: 'keydown', fn: onKeyDown }, + { target: window, type: 'keyup', fn: onKeyUp }, + { target: window, type: 'blur', fn: onBlur }, + ]; + } + + /** + * Двигаем камеру по WASDQE — работает всегда, не требует зажатой ПКМ + * (Minecraft Creative-style — удобнее чем Roblox для редактирования сцены). + * ПКМ нужна только для поворота камеры. + * Вызывается каждый кадр из render loop. + * Используем e.code — независимо от раскладки клавиатуры. + */ + _updateCameraMovement() { + if (this._isPlaying) return; // в режиме игры редактор-камера не движется + const c = this._codes; + if (c.size === 0) return; + + const dt = this.engine.getDeltaTime() / 1000; + const speed = this.MOVE_SPEED * dt * (this._shiftDown ? this.SHIFT_MULTIPLIER : 1); + + const forward = this._getCameraForward(); + const right = this._getCameraRight(); + const worldUp = new Vector3(0, 1, 0); + const move = Vector3.Zero(); + + if (c.has('KeyW') || c.has('ArrowUp')) move.addInPlace(forward.scale(speed)); + if (c.has('KeyS') || c.has('ArrowDown')) move.addInPlace(forward.scale(-speed)); + if (c.has('KeyD') || c.has('ArrowRight')) move.addInPlace(right.scale(speed)); + if (c.has('KeyA') || c.has('ArrowLeft')) move.addInPlace(right.scale(-speed)); + if (c.has('KeyE') || c.has('Space')) move.addInPlace(worldUp.scale(speed)); + if (c.has('KeyQ')) move.addInPlace(worldUp.scale(-speed)); + + if (move.lengthSquared() > 0) { + this.camera.position.addInPlace(move); + } + } + + /** + * Единичный вектор «вперёд» камеры (с учётом её поворота). + */ + _getCameraForward() { + const yaw = this.camera.rotation.y; + const pitch = this.camera.rotation.x; + return new Vector3( + Math.sin(yaw) * Math.cos(pitch), + -Math.sin(pitch), + Math.cos(yaw) * Math.cos(pitch) + ).normalize(); + } + + /** + * Единичный вектор «вправо» камеры (перпендикуляр forward в горизонтальной плоскости). + */ + _getCameraRight() { + const yaw = this.camera.rotation.y; + return new Vector3(Math.cos(yaw), 0, -Math.sin(yaw)).normalize(); + } + + /** + * Единичный вектор «вверх» относительно камеры. + */ + _getCameraUp() { + const forward = this._getCameraForward(); + const right = this._getCameraRight(); + return Vector3.Cross(right, forward).normalize(); + } + + /** + * Фокус на точке: ставим камеру в 15 единицах от target по текущему направлению. + * Будет использоваться для F — focus on selected. + */ + _focusOnTarget(target) { + const offset = this._getCameraForward().scale(-15); + this.camera.position = target.add(offset); + this.camera.setTarget(target); + } + + // === ИНСТРУМЕНТЫ И БЛОКИ =========================================== + + /** + * Создать "призрачный" блок — полупрозрачный преview, показывает где + * появится блок при клике. + */ + _createGhostBlock() { + const ghost = MeshBuilder.CreateBox('ghostBlock', { size: 1.02 }, this.scene); + const mat = new StandardMaterial('ghostMat', this.scene); + mat.diffuseColor = new Color3(0.4, 0.9, 0.4); + mat.alpha = 0.35; + mat.specularColor = new Color3(0, 0, 0); + mat.disableLighting = true; + ghost.material = mat; + ghost.isPickable = false; // raycast его игнорирует + ghost.setEnabled(false); + this._ghostMesh = ghost; + } + + /** + * Создать видимый маркер точки спавна — полупрозрачный жёлтый цилиндр со + * светящейся вершиной. Виден только в редакторе, скрывается в Play. + */ + _createSpawnMarker() { + // Базовый цилиндр-подставка + const base = MeshBuilder.CreateCylinder( + 'spawnMarkerBase', + { diameterTop: 1.0, diameterBottom: 1.2, height: 0.15, tessellation: 24 }, + this.scene + ); + const baseMat = new StandardMaterial('spawnBaseMat', this.scene); + baseMat.diffuseColor = new Color3(0.95, 0.75, 0.2); + baseMat.emissiveColor = new Color3(0.3, 0.2, 0); + baseMat.specularColor = new Color3(0, 0, 0); + baseMat.alpha = 0.85; + base.material = baseMat; + + // Внутренний светящийся столб + const beam = MeshBuilder.CreateCylinder( + 'spawnMarkerBeam', + { diameter: 0.4, height: 2.5, tessellation: 16 }, + this.scene + ); + const beamMat = new StandardMaterial('spawnBeamMat', this.scene); + beamMat.diffuseColor = new Color3(1, 0.9, 0.3); + beamMat.emissiveColor = new Color3(1, 0.85, 0.2); + beamMat.specularColor = new Color3(0, 0, 0); + beamMat.alpha = 0.4; + beamMat.disableLighting = true; + beam.material = beamMat; + beam.position.y = 1.3; + + // Группируем base+beam в TransformNode чтобы двигать как одно + const root = new TransformNode('spawnMarker', this.scene); + base.parent = root; + beam.parent = root; + root.position = new Vector3(this._spawnPoint.x, this._spawnPoint.y, this._spawnPoint.z); + + // Делаем маркер pickable, чтобы можно было кликнуть и выделить. + // Метаданные для SelectionManager: { isSpawn: true }. + base.isPickable = true; + beam.isPickable = true; + base.metadata = { isSpawn: true }; + beam.metadata = { isSpawn: true }; + + this._spawnMarker = root; + this._spawnMarkerMeshes = [base, beam]; + } + + /** Обновить позицию визуального маркера спавна. */ + _updateSpawnMarker() { + if (!this._spawnMarker) return; + this._spawnMarker.position.set( + this._spawnPoint.x, + this._spawnPoint.y, + this._spawnPoint.z + ); + } + + /** Скрыть/показать маркер спавна. */ + _setSpawnMarkerVisible(visible) { + if (!this._spawnMarker) return; + this._spawnMarker.setEnabled(visible); + // КРИТИЧНО: при скрытии маркера в Play также делаем его непикаемым. + // Babylon `pickWithRay` ловит меши даже при `setEnabled(false)` если + // disabled у parent TransformNode. Без isPickable=false луч стрельбы + // попадает в столб маркера в 5м перед игроком. + if (this._spawnMarkerMeshes) { + for (const m of this._spawnMarkerMeshes) { + if (m) m.isPickable = visible; + } + } + } + + /** + * Raycast от курсора в сцену. + * Возвращает { mesh, point, normal } либо null если ни во что не попали. + * Игнорирует ghost-блок и линии сетки. + */ + /** + * Нормализация клавиши из KeyboardEvent в простую строку для game.onKey. + * KeyW → 'w', Space → 'space', ArrowUp → 'arrowup', ShiftLeft → 'shift', ... + */ + _normalizeKey(e) { + const code = e.code || ''; + // Буквы KeyA..KeyZ → 'a'..'z' + if (/^Key[A-Z]$/.test(code)) return code.charAt(3).toLowerCase(); + // Цифры Digit0..Digit9 → '0'..'9' + if (/^Digit\d$/.test(code)) return code.charAt(5); + // Спецклавиши + const map = { + Space: 'space', + Enter: 'enter', + NumpadEnter: 'enter', + Escape: 'escape', + Tab: 'tab', + Backspace: 'backspace', + ShiftLeft: 'shift', ShiftRight: 'shift', + ControlLeft: 'ctrl', ControlRight: 'ctrl', + AltLeft: 'alt', AltRight: 'alt', + ArrowUp: 'arrowup', ArrowDown: 'arrowdown', + ArrowLeft: 'arrowleft', ArrowRight: 'arrowright', + }; + if (map[code]) return map[code]; + // Fallback — сам key в lower-case + return String(e.key || code).toLowerCase(); + } + + /** + * Pick по центру экрана (для Play-режима где курсор залочен). + * Используется для game.self.onClick — клик луч-форвард игрока. + */ + _pickFromCenter() { + const w = this.engine?.getRenderWidth?.() || this.canvas.width; + const h = this.engine?.getRenderHeight?.() || this.canvas.height; + const pi = this.scene.pick(w / 2, h / 2, (mesh) => { + if (!mesh.isPickable) return false; + if (mesh === this._ghostMesh) return false; + if (mesh.name && mesh.name.startsWith('gridLine')) return false; + return true; + }); + if (!pi || !pi.hit) return null; + let mesh = pi.pickedMesh; + if (mesh?.metadata?._isBlockProto && this.blockManager) { + const proxy = this.blockManager.findProxyByPickInfo(pi); + if (proxy) mesh = proxy; + } + return { mesh, point: pi.pickedPoint, pickInfo: pi }; + } + + /** + * Извлечь target {kind, ref} из mesh (proxy/прим/модель). + * Используется при клике/touch в Play. + */ + _meshToTarget(mesh) { + if (!mesh || !mesh.metadata) return null; + const md = mesh.metadata; + if (md.isBlock) { + return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } }; + } + if (md.isModel) return { kind: 'model', id: md.instanceId }; + if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId }; + return null; + } + + /** + * Детекция касания игроком объектов с target-скриптами. + * Для каждого target-скрипта проверяем AABB-overlap с игроком. + * Событие 'touch' эмитится один раз на «вход» (на rising edge) — пока + * игрок не выйдет из объекта и не вернётся, повторно touch не вызывается. + */ + _detectTouchEvents() { + const rt = this.gameRuntime; + if (!rt || !this.player?._pos) return; + const scripts = this._scripts || []; + if (scripts.length === 0) return; + // Кэш «контакта»: scriptId → true если сейчас касается + if (!this._touchState) this._touchState = new Map(); + const seen = new Set(); + const px = this.player._pos.x; + const py = this.player._pos.y; // центр капсулы + const pz = this.player._pos.z; + const phw = this.player.HALF_W ?? 0.3; + const phh = this.player.HALF_H ?? 0.9; + const phd = this.player.HALF_D ?? 0.3; + + // EPS — допуск касания. Когда игрок СТОИТ на объекте сверху, + // низ его капсулы строго совпадает с верхом объекта (зазор 0), + // и строгое сравнение AABB даёт «не пересекаются». Расширяем + // зону на EPS, чтобы «стоит на объекте/вплотную» = касание. + // Без этого onTouch финиша/плитки не срабатывает (игрок встал). + const EPS = 0.25; + + // 1) Касания объектов с target-скриптами (ключ touchState = 's:'+scriptId) + for (const s of scripts) { + if (!s.target) continue; + const key = 's:' + s.id; + seen.add(key); + const aabb = this._targetAABB(s.target); + if (!aabb) continue; + const overlap = + px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS && + py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS && + pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS; + const wasTouching = this._touchState.get(key); + if (overlap && !wasTouching) { + this._touchState.set(key, true); + rt.routeEvent(s.target, 'touch', {}); + rt.routeGlobalEvent('playerTouch', { target: s.target }); + } else if (!overlap && wasTouching) { + this._touchState.set(key, false); + rt.routeEvent(s.target, 'untouch', {}); + } + } + + // 2) Касания примитивов-триггеров (type === 'trigger') БЕЗ скрипта — + // шлём глобальное playerTouch с target. Это позволяет писать + // логику чек-поинтов в одном глобальном скрипте без скриптов на каждом + // триггере. Ключ: 'p:'+id, чтобы не пересекаться со скриптами. + // Сюда же — примитивы, заспавненные скриптом (data._scriptSpawned): + // для них тоже шлём playerTouch, чтобы игры «поймай объект» + // могли ловить падающие кубы через game.onPlayerTouch. + const prims = this.primitiveManager?.instances; + if (prims && prims.size > 0) { + for (const data of prims.values()) { + const isTrigger = data?.type === 'trigger'; + const isSpawned = data?._scriptSpawned === true; + if (!isTrigger && !isSpawned) continue; + const id = data.id; + // Если на этот примитив УЖЕ повешен target-скрипт — он + // обработан в блоке выше, чтобы не дублировать события. + const hasScript = scripts.some(s => + s.target?.kind === 'primitive' && (s.target.id ?? s.target.ref) === id + ); + if (hasScript) continue; + const key = 'p:' + id; + seen.add(key); + const hx = data.sx / 2, hy = data.sy / 2, hz = data.sz / 2; + const overlap = + px + phw > data.x - hx - EPS && px - phw < data.x + hx + EPS && + py + phh > data.y - hy - EPS && py - phh < data.y + hy + EPS && + pz + phd > data.z - hz - EPS && pz - phd < data.z + hz + EPS; + const wasTouching = this._touchState.get(key); + if (overlap && !wasTouching) { + this._touchState.set(key, true); + // target — строка-ref 'primitive:': её можно + // передать в game.scene.delete и сравнивать. + rt.routeGlobalEvent('playerTouch', { + target: 'primitive:' + id, + }); + } else if (!overlap && wasTouching) { + this._touchState.set(key, false); + } + } + } + + // 3) Касания объектов, на которые подписан ГЛОБАЛЬНЫЙ скрипт через + // findOne(x).onTouch(...) (rt._watchedTouchRefs). Объекты без скрипта + // и не триггеры — например цели туториала. Событие адресное (по ref). + const watched = rt._watchedTouchRefs; + if (watched && watched.size > 0) { + for (const ref of watched) { + const target = this._refToTarget(ref); + if (!target) continue; + const aabb = this._targetAABB(target); + if (!aabb) continue; + const key = 'w:' + ref; + seen.add(key); + const overlap = + px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS && + py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS && + pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS; + const wasTouching = this._touchState.get(key); + if (overlap && !wasTouching) { + this._touchState.set(key, true); + rt.routeInstEvent(ref, 'instTouch', {}); + } else if (!overlap && wasTouching) { + this._touchState.set(key, false); + rt.routeInstEvent(ref, 'instUntouch', {}); + } + } + } + + // Чистим устаревшие записи (удалённые скрипты/триггеры) + for (const id of this._touchState.keys()) { + if (!seen.has(id)) this._touchState.delete(id); + } + } + + /** Ref-строка 'primitive:NN' | 'model:NN' → {kind,id} для _targetAABB. */ + _refToTarget(ref) { + if (typeof ref !== 'string') return null; + const colon = ref.indexOf(':'); + if (colon < 0) return null; + const kind = ref.slice(0, colon); + const rest = ref.slice(colon + 1); + if (kind === 'primitive') { + const id = this.gameRuntime?._resolvePrimitiveId + ? this.gameRuntime._resolvePrimitiveId(rest) + : (Number.isFinite(Number(rest)) ? Number(rest) : rest); + return { kind: 'primitive', id }; + } + if (kind === 'model') { + const n = Number(rest); + return { kind: 'model', id: Number.isFinite(n) ? n : rest }; + } + return null; + } + + /** Получить мировой AABB target-объекта (для touch-детекции). */ + _targetAABB(target) { + if (!target) return null; + try { + if (target.kind === 'block') { + const r = target.ref || target; + return { + minX: r.x - 0.5, maxX: r.x + 0.5, + minY: r.y, maxY: r.y + 1, + minZ: r.z - 0.5, maxZ: r.z + 0.5, + }; + } + if (target.kind === 'model') { + const id = target.id ?? target.ref; + return this.modelManager?.getInstanceAABB?.(id) || null; + } + if (target.kind === 'primitive') { + const id = target.id ?? target.ref; + const data = this.primitiveManager?.instances?.get(id); + if (!data) return null; + const hx = data.sx / 2, hy = data.sy / 2, hz = data.sz / 2; + return { + minX: data.x - hx, maxX: data.x + hx, + minY: data.y - hy, maxY: data.y + hy, + minZ: data.z - hz, maxZ: data.z + hz, + }; + } + } catch (e) { /* ignore */ } + return null; + } + + /** + * Обработка клика в Play-режиме. + * Делает forward-pick и роутит click-событие: + * - в self-обработчики скриптов (routeEvent с target) + * - в глобальные обработчики (game.onClick) с event.target + */ + _handlePlayClick(clickX, clickY) { + if (!this._isPlaying) return; + + // Мультиплеер-выстрел: если у сцены есть mpSync, шлём 'shoot' серверу. + // Используем forward-вектор игрока (XZ-плоскость) — куда смотрит, + // туда и выстрел. На сервере дальше идёт raycast по другим игрокам. + if (this._mpSync && this.player?._pos) { + try { + const yaw = this.player._yaw || 0; + // forward в плоскости XZ: yaw=0 — смотрим в +Z + const dirX = Math.sin(yaw); + const dirZ = Math.cos(yaw); + this._mpSync.sendShoot( + this.player._pos.x, + this.player._pos.z, + dirX, dirZ, + ); + } catch (e) { /* room closed / mpSync disposed */ } + } + + if (!this.gameRuntime) return; + + // === Задача 01: клик по КНОПКЕ 3D-таблички (billboard) === + // При pointer-lock (first/shift-lock) курсор в центре экрана → пикаем + // из центра. В third курсор СВОБОДНЫЙ → пикаем по реальным координатам + // клика (clickX/clickY переданы из onMouseDown). Без этого клик по + // табличке мышью в third промахивался — кнопки не нажимались. + if (this.billboardUiManager && this.primitiveManager) { + const locked = (document.pointerLockElement === this.canvas); + const w = this.engine?.getRenderWidth?.() || this.canvas.width; + const h = this.engine?.getRenderHeight?.() || this.canvas.height; + const px = locked ? w / 2 : (Number.isFinite(clickX) ? clickX : w / 2); + const py = locked ? h / 2 : (Number.isFinite(clickY) ? clickY : h / 2); + const bpick = this.scene.pick(px, py, (m) => + m && m.metadata && m.metadata.primitiveId != null + && this.primitiveManager.instances.get(m.metadata.primitiveId)?.type === 'billboard'); + if (bpick && bpick.hit && bpick.pickedMesh) { + const bdata = this.primitiveManager.instances.get(bpick.pickedMesh.metadata.primitiveId); + const uv = bpick.getTextureCoordinates ? bpick.getTextureCoordinates() : null; + if (bdata && uv) { + const buttonId = this.billboardUiManager.pickButtonAt(bdata, uv.x, uv.y); + console.log('[billboard] клик id=' + bpick.pickedMesh.metadata.primitiveId + + ' uv=(' + uv.x.toFixed(2) + ',' + uv.y.toFixed(2) + ') buttonId=' + buttonId + + ' locked=' + locked); + if (buttonId) { + this.billboardUiManager.fireClick(bdata, buttonId); + return; // клик по табличке обработан + } + } else { + console.log('[billboard] попал в табличку id=' + + bpick.pickedMesh.metadata.primitiveId + ' но нет UV'); + } + } + } + + 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). + try { + const active = this.inventory?.getActive?.(); + if (active) { + this.gameRuntime.routeGlobalEvent('toolUse', { + tool: { + kind: active.kind, + modelTypeId: active.modelTypeId, + name: active.name, + }, + point, target, + }); + } + } catch (e) { /* ignore */ } + } + + /** + * Установить мультиплеер-синхронизатор. Вызывается из KubikonPlayer + * после joinOrCreate. При null — отключаем мультиплеер. + */ + /** Тач-режим — управление через виртуальный джойстик и тач-свайп камеры. + * Должно вызываться ДО enterPlayMode, иначе PlayerController создастся + * с дефолтным mouse/keyboard-управлением. */ + setTouchMode(enabled) { + this._touchMode = !!enabled; + // Если уже в Play и есть player — пробрасываем + if (this.player && typeof this.player.setTouchMode === 'function') { + this.player.setTouchMode(this._touchMode); + } + // На тач-устройствах (= мобила/планшет) включаем low-perf-режим + // автоматически: уменьшаем разрешение рендера, отключаем тени, + // увеличиваем dt физики. Это даёт ×2-3 прирост FPS. + if (this._touchMode) this.applyLowPerfMode(); + } + + /** + * Включить «лёгкий» режим рендера для слабых устройств / мобилок. + * Можно вызвать вручную и на десктопе если FPS проседает. + * + * Главная идея: не пикселим картинку, а уменьшаем нагрузку безболезненно: + * 1. DPR-нормализация: рендерим в DPR=1 (а не ×2-3 как Retina по умолчанию). + * Это даёт х2-х9 буст FPS без видимой потери качества — глаз + * телефонного экрана не различает разницу между DPR=2 и DPR=1. + * В отличие от scalingLevel=2 (рендер в половину родного), здесь + * текстуры остаются чёткими — рендерим в реальное число css-пикселей. + * 2. Отключаем тени — ShadowGenerator самый дорогой эффект. + * 3. skipPointerMovePicking — не делаем raycast от мыши на каждый move. + * 4. maxZ=200 — урезаем дальность рендера. + * + * НЕ делаем (после теста — слишком ухудшало картинку и плавность): + * - hardwareScalingLevel > 1 (давало пикселизацию текстур) + * - FPS-cap 30 через frame-skip (давало дёрганье движения) + * - ZombieManager тик через кадр (тоже дёрганье) + */ + applyLowPerfMode() { + if (this._lowPerfApplied) return; + this._lowPerfApplied = true; + // НЕ трогаем hardwareScalingLevel — оставляем нативное разрешение + // экрана (включая DPR). На современных телефонах GPU справляется, + // а текстуры и текст ника остаются чёткими. Прирост FPS даём за + // счёт отключённых теней / AA / maxZ=200, а не уменьшения буфера. + // Тени — выключаем + try { + if (this._shadowGenerator) { + this._shadowGenerator.dispose(); + this._shadowGenerator = null; + } + this._shadowQuality = 'off'; + } catch (e) { /* ignore */ } + // Скип pointer-move picking — каждый кадр не делаем raycast от мыши + try { this.scene.skipPointerMovePicking = true; } catch (e) {} + // НЕ включаем blockMaterialDirtyMechanism — он ломает свойства + // материалов трейсеров/дебриса (создаются после старта, шейдер не + // пересчитывается, emissiveColor/alpha/disableLighting не применяются). + try { + if (this.scene) { + this.scene.autoClear = true; + this.scene.autoClearDepthAndStencil = true; + } + } catch (e) {} + // Уменьшаем дальность рендера если камера далеко смотрит + try { + if (this.camera && this.camera.maxZ) { + this.camera.maxZ = Math.min(this.camera.maxZ, 200); + } + } catch (e) {} + // FPS-cap НЕ ставим — лучше нативные 60 FPS если устройство тянет. + this._lowPerfFrameSkip = false; + // eslint-disable-next-line no-console + console.log('[BabylonScene] low-perf mode applied (DPR-normalized, no shadows, no frame-skip)'); + } + + setMultiplayerSync(sync) { + this._mpSync = sync; + // Сразу шлём текущее активное оружие, чтобы remote-клиенты + // увидели его в руке модели сразу после нашего onAdd. + if (sync) { + try { + const active = this.inventory?.getActive?.(); + const modelId = (active && active.kind === 'weapon') + ? (active.modelTypeId || '') + : ''; + sync.sendWeapon(modelId); + } catch (e) { /* ignore */ } + } + } + + _pickFromMouse() { + // 1) Стандартный pick — для моделей, примитивов, пола, ghost'ов и т.п. + // Блоки рисуются через thin-instances; их proto-меш ИГНОРИРУЕМ + // в этом проходе (он бы вернул thin instance с потерянным индексом + // в новых версиях Babylon — старая боль с выделением и постановкой). + const pi = this.scene.pick( + this.scene.pointerX, + this.scene.pointerY, + (mesh) => { + if (!mesh.isPickable) return false; + if (mesh === this._ghostMesh) return false; + if (mesh.name && mesh.name.startsWith('gridLine')) return false; + if (mesh.metadata?._isBlockProto) return false; // ⬅ важно! + return true; + } + ); + + // 2) Отдельный пик блоков через свой raycast по AABB-сетке. + // Гораздо надёжнее thin-instance pick'а: даём гарантированный + // proxy + нормаль грани попадания. + const blockHit = this._pickBlockManually(); + + // Выбираем ближайший: либо стандартный pick, либо блок. + if (pi && pi.hit && blockHit) { + // Сравниваем по дистанции от камеры + const cam = this.scene.activeCamera?.position; + if (cam) { + const d1Sq = (pi.pickedPoint.x - cam.x) ** 2 + + (pi.pickedPoint.y - cam.y) ** 2 + + (pi.pickedPoint.z - cam.z) ** 2; + const d2Sq = (blockHit.point.x - cam.x) ** 2 + + (blockHit.point.y - cam.y) ** 2 + + (blockHit.point.z - cam.z) ** 2; + if (d2Sq < d1Sq) { + return blockHit; + } + } + return { + mesh: pi.pickedMesh, + point: pi.pickedPoint, + normal: pi.getNormal(true), + pickInfo: pi, + }; + } + if (blockHit) return blockHit; + if (pi && pi.hit) { + return { + mesh: pi.pickedMesh, + point: pi.pickedPoint, + normal: pi.getNormal(true), + pickInfo: pi, + }; + } + return null; + } + + /** + * Свой raycast по блокам. Идёт от камеры в направлении курсора, проходит + * по сетке и проверяет каждую клетку: есть ли блок в blockManager.blocks? + * Возвращает { mesh: proxy, point, normal } или null. + * + * Используется DDA (digital differential analyzer) — самый быстрый алгоритм + * для voxel-raycast. + */ + _pickBlockManually() { + if (!this.blockManager || !this.scene.activeCamera) return null; + // Получаем ray из курсора + const camera = this.scene.activeCamera; + const ray = this.scene.createPickingRay( + this.scene.pointerX, this.scene.pointerY, null, camera + ); + const origin = ray.origin; + const dir = ray.direction; + + // DDA для voxel-сетки. + // Стартуем с клетки в которой находится origin + let x = Math.round(origin.x); + let y = Math.floor(origin.y); + let z = Math.round(origin.z); + + // Шаги по каждой оси + const stepX = dir.x > 0 ? 1 : -1; + const stepY = dir.y > 0 ? 1 : -1; + const stepZ = dir.z > 0 ? 1 : -1; + + // Длина шага вдоль луча для перехода на следующую клетку + const tDeltaX = Math.abs(dir.x) > 1e-8 ? Math.abs(1 / dir.x) : Infinity; + const tDeltaY = Math.abs(dir.y) > 1e-8 ? Math.abs(1 / dir.y) : Infinity; + const tDeltaZ = Math.abs(dir.z) > 1e-8 ? Math.abs(1 / dir.z) : Infinity; + + // Расстояние до первой границы клетки + // Блок (x,y,z) занимает X: x-0.5..x+0.5, Y: y..y+1, Z: z-0.5..z+0.5 + const nextBoundaryX = x + 0.5 * stepX; + const nextBoundaryY = stepY > 0 ? (y + 1) : y; + const nextBoundaryZ = z + 0.5 * stepZ; + + let tMaxX = Math.abs(dir.x) > 1e-8 ? (nextBoundaryX - origin.x) / dir.x : Infinity; + let tMaxY = Math.abs(dir.y) > 1e-8 ? (nextBoundaryY - origin.y) / dir.y : Infinity; + let tMaxZ = Math.abs(dir.z) > 1e-8 ? (nextBoundaryZ - origin.z) / dir.z : Infinity; + + const MAX_STEPS = 200; // максимум 200 клеток по лучу + const MAX_DIST = 100; // и не дальше 100м + + // Какая ось пересечена последней (для вычисления нормали) + let lastAxis = -1; + + for (let i = 0; i < MAX_STEPS; i++) { + // Проверяем клетку (x, y, z) + if (y >= 0 && y < 200) { + const key = `${x},${y},${z}`; + const proxy = this.blockManager.blocks.get(key); + if (proxy && proxy.metadata?.canCollide !== false) { + // Нашли! Вычисляем точку контакта и нормаль. + let t; + let nx = 0, ny = 0, nz = 0; + if (lastAxis === 0) { + // Зашли через X-грань + t = tMaxX - tDeltaX; + nx = -stepX; + } else if (lastAxis === 1) { + t = tMaxY - tDeltaY; + ny = -stepY; + } else if (lastAxis === 2) { + t = tMaxZ - tDeltaZ; + nz = -stepZ; + } else { + // Стартуем уже внутри клетки — нормаль вверх по умолчанию + t = 0; + ny = 1; + } + if (t > MAX_DIST) return null; + const point = { + x: origin.x + dir.x * t, + y: origin.y + dir.y * t, + z: origin.z + dir.z * t, + }; + return { + mesh: proxy, + point: { x: point.x, y: point.y, z: point.z, clone() { return { x: this.x, y: this.y, z: this.z }; } }, + normal: { x: nx, y: ny, z: nz }, + pickInfo: null, + }; + } + } + // Шаг по ближайшей оси + if (tMaxX < tMaxY && tMaxX < tMaxZ) { + if (tMaxX > MAX_DIST) return null; + x += stepX; + tMaxX += tDeltaX; + lastAxis = 0; + } else if (tMaxY < tMaxZ) { + if (tMaxY > MAX_DIST) return null; + y += stepY; + tMaxY += tDeltaY; + lastAxis = 1; + } else { + if (tMaxZ > MAX_DIST) return null; + z += stepZ; + tMaxZ += tDeltaZ; + lastAxis = 2; + } + } + return null; + } + + + /** + * Обновить позицию ghost-блока под курсором. + * Вызывается каждый кадр когда tool='block'. + */ + _updateGhostPosition() { + if (!this._ghostMesh) return; + if (this._isPlaying) { + this._ghostMesh.setEnabled(false); + return; + } + if (this._activeTool !== 'block' && this._activeTool !== 'model') { + this._ghostMesh.setEnabled(false); + return; + } + const pick = this._pickFromMouse(); + if (!pick) { + this._ghostMesh.setEnabled(false); + return; + } + const target = this._computePlacementCell(pick); + if (!target) { + this._ghostMesh.setEnabled(false); + return; + } + // Не показываем ghost если в этой клетке уже блок (только для tool=block) + if (this._activeTool === 'block' && + this.blockManager?.hasBlock(target.x, target.y, target.z)) { + this._ghostMesh.setEnabled(false); + return; + } + this._ghostMesh.position = new Vector3(target.x, target.y + 0.5, target.z); + // Для модели — отображаем угол поворота через rotation (визуальная подсказка) + if (this._activeTool === 'model') { + this._ghostMesh.rotation.y = this._ghostRotationY; + } else { + this._ghostMesh.rotation.y = 0; + } + this._ghostMesh.setEnabled(true); + } + + /** + * Высчитать целочисленную клетку (gridX, gridY, gridZ) куда ставить блок. + * Координаты — это нижний-передний-левый угол клетки (блок занимает + * (gridX..gridX+1, gridY..gridY+1, gridZ..gridZ+1)). + * + * Попали в блок: новая клетка = соседняя по нормали грани. + * Попали в пол: новая клетка = (round(p.x - 0.5), 0, round(p.z - 0.5)). + * + * Почему -0.5: точка p.x на полу — это координата в мире (0..40). Сетка + * целочисленная: блок «(0,0,0)» занимает (-0.5..0.5, 0..1, -0.5..0.5). + * Чтобы клик точно в центр клетки попал в (0,0,0), нужно округление + * без сдвига. Math.round(0.4) = 0, Math.round(0.6) = 1 — правильно. + */ + _computePlacementCell(pick) { + const p = pick.point; + const n = pick.normal || new Vector3(0, 1, 0); + const mesh = pick.mesh; + + if (mesh?.metadata?.isBlock) { + // Соседняя клетка по нормали грани, в которую попали + const m = mesh.metadata; + const nx = Math.round(n.x); + const ny = Math.round(n.y); + const nz = Math.round(n.z); + const cell = { + x: m.gridX + nx, + y: m.gridY + ny, + z: m.gridZ + nz, + }; + if (cell.y < 0) return null; + return cell; + } + + // Попали в ТЕРРЕЙН (воксельный region-mesh или гладкий roblox-terrain). + // У этих мешей нет metadata.isBlock, но есть свои метки. Берём + // РЕАЛЬНУЮ точку пересечения луча (p.y) — это высота поверхности + // там, куда кликнули. Без этого модель вставала на y=0 (baseplate). + const md = mesh?.metadata; + const isTerrain = md && (md._isTerrainProto || md._isRegionMesh || md._isRobloxTerrain); + if (isTerrain) { + return { + x: Math.round(p.x), + y: p.y, // реальная высота поверхности под курсором + z: Math.round(p.z), + }; + } + + // Попали в пол / прочее. Точка p — мировая. Блок «(ix,iy,iz)» имеет + // центр на (ix, iy+0.5, iz), его горизонтальные грани (x,z) от (ix-0.5) + // до (ix+0.5). Поэтому простое Math.round(p.x) даёт верный gridX. + const x = Math.round(p.x); + const z = Math.round(p.z); + // Если попали в верхнюю грань пола → ставим на y=0. + // Если попали под низ пола (камера ниже сцены) → не ставим. + if (n.y < 0.5) return null; + return { x, y: 0, z }; + } + + /** + * Обработать клик мыши (вызывается из mouseup если это был клик, не drag). + * tool: 'block' / 'model' / 'erase' / 'select'. + */ + _handleEditorClick(shiftKey, ctrlKey = false) { + if (this._isPlaying) return; + if (!this.blockManager) return; + const pick = this._pickFromMouse(); + if (!pick) { + if (this._activeTool === 'select' && !ctrlKey) { + this.selection?.clear(); + } + return; + } + + const tool = shiftKey ? 'erase' : this._activeTool; + + if (tool === 'select') { + if (this.selection) { + // Для надёжности: если pick.mesh почему-то остался прото-мешем + // (без metadata.isBlock), пробуем разрезолвить через + // findProxyByPickInfo ещё раз. + let selectMesh = pick.mesh; + if (selectMesh?.metadata?._isBlockProto && this.blockManager) { + const proxy = this.blockManager.findProxyByPickInfo(pick.pickInfo); + if (proxy) selectMesh = proxy; + } + if (ctrlKey) { + this.selection.toggleMeshSelection(selectMesh); + } else { + this.selection.selectByMesh(selectMesh); + } + } + } else if (tool === 'block') { + const target = this._computePlacementCell(pick); + if (!target) return; + // Блоки живут в целочисленной сетке. Если кликнули по террейну, + // _computePlacementCell вернёт нецелый y (реальная высота + // поверхности) — округляем, чтобы блок встал ровно в клетку. + const by = Math.round(target.y); + const mesh = this.blockManager.addBlock(target.x, by, target.z, this._activeBlockType); + this._lastPlacedKey = `${target.x},${by},${target.z}`; + // Авто-выделение поставленного блока. Тени уже работают через proto-меш + // (зарегистрирован в refreshAllShadows и обновляется автоматически). + if (mesh) { + this.selection?.selectBlockAt(target.x, by, target.z); + if (this._onPostPlace) this._onPostPlace(); + } + } else if (tool === 'model') { + if (!this._activeModelType) return; + const cell = this._computePlacementCell(pick); + if (!cell) return; + // Пользовательская voxel-модель (id 'user:') — отдельный путь. + if (typeof this._activeModelType === 'string' + && this._activeModelType.startsWith(USER_MODEL_PREFIX)) { + this.userModelManager.addInstance( + this._activeModelType, cell.x, cell.y, cell.z, this._ghostRotationY, + { currentUserId: this._currentUserId || null }, + ).then(instId => { + if (instId != null) { + const data = this.userModelManager.instances.get(instId); + if (data?.meshes) { + for (const m of data.meshes) this.addShadowCaster(m); + } + // Регистрируем коллайдер + this._syncUserModelColliders(); + if (this._onPostPlace) this._onPostPlace(); + try { this._onSceneChange?.(); } catch (e) {} + // Инкремент uses_count — fire-and-forget + const numericId = parseUserModelId(this._activeModelType); + if (numericId != null && this._userModelsApi?.incrementModelUses) { + this._userModelsApi.incrementModelUses(numericId) + .catch(() => {}); + } + } + }); + return; + } + // addInstance модели — async, ждём id и выделяем + Promise.resolve(this.modelManager.addInstance( + this._activeModelType, cell.x, cell.y, cell.z, this._ghostRotationY + )).then(instId => { + if (instId != null) { + const data = this.modelManager.instances.get(instId); + if (data?.rootMesh && typeof data.rootMesh.getChildMeshes === 'function') { + // rootMesh — TransformNode, пропускаем его и берём только меши + for (const cm of data.rootMesh.getChildMeshes()) this.addShadowCaster(cm); + } + this.selection?.selectModelByInstanceId(instId); + if (this._onPostPlace) this._onPostPlace(); + } + }); + } else if (tool === 'primitive') { + if (!this._activePrimitiveType) return; + const cell = this._computePlacementCell(pick); + if (!cell) return; + // Примитив ставим так чтобы его НИЖНЯЯ грань была на клетке. + // Для куба/цилиндра/конуса pivot — центр, поэтому добавляем halfHeight. + const def = getPrimitiveType(this._activePrimitiveType); + const halfH = (def.defaultScale.y) / 2; + const newId = this.primitiveManager.addInstance(this._activePrimitiveType, { + x: cell.x, y: cell.y + halfH, z: cell.z, + }); + // Авто-выделение поставленного примитива + if (newId != null) { + const data = this.primitiveManager.instances.get(newId); + if (data?.mesh) this.addShadowCaster(data.mesh); + this.selection?.selectPrimitiveById(newId); + if (this._onPostPlace) this._onPostPlace(); + } + } else if (tool === 'erase') { + if (pick.mesh?.metadata?.isBlock) { + this.blockManager.removeBlockByMesh(pick.mesh); + } else if (pick.mesh?.metadata?.isModel) { + this.modelManager.removeInstanceByMesh(pick.mesh); + } else if (pick.mesh?.metadata?.isPrimitive) { + this.primitiveManager.removeInstanceByMesh(pick.mesh); + } + } + } + + /** + * Drag-постановка/удаление блоков. Вызывается на mousemove когда ЛКМ + * удерживается и активен tool=block/erase. + * + * Чтобы блоки не «лезли на игрока» при ведении мышью по сцене, фиксируем + * плоскость первого блока (X/Y/Z в зависимости от грани попадания): + * - Кликнул на пол / верх блока → drag по горизонтали (фиксируем Y) + * - Кликнул на боковую грань (по X) → drag по вертикальной плоскости (фиксируем X) + * - и т.д. + * + * isFirst=true — это первый клик drag'а, запоминаем ось фиксации. + */ + _dragPlaceTick(shiftKey, isFirst = false) { + if (this._isPlaying || !this.blockManager) return; + const tool = shiftKey ? 'erase' : this._activeTool; + if (tool !== 'block' && tool !== 'erase') return; + + const pick = this._pickFromMouse(); + if (!pick) return; + + if (tool === 'block') { + const target = this._computePlacementCell(pick); + if (!target) return; + + // Первый клик — запоминаем ось фиксации по нормали попадания + if (isFirst) { + const n = pick.normal; + if (Math.abs(n.y) > 0.5) { + this._dragLockAxis = 'y'; + this._dragLockValue = target.y; + } else if (Math.abs(n.x) > 0.5) { + this._dragLockAxis = 'x'; + this._dragLockValue = target.x; + } else if (Math.abs(n.z) > 0.5) { + this._dragLockAxis = 'z'; + this._dragLockValue = target.z; + } else { + this._dragLockAxis = null; + } + } else if (this._dragLockAxis) { + // На последующих движениях — переопределяем target в плоскости + if (target[this._dragLockAxis] !== this._dragLockValue) { + // Курсор ушёл с зафиксированной плоскости. Пересчитываем + // через raycast на полу/блоке, но с принудительной координатой. + target[this._dragLockAxis] = this._dragLockValue; + } + } + + const key = `${target.x},${target.y},${target.z}`; + if (key === this._lastPlacedKey) return; + if (this.blockManager.hasBlock(target.x, target.y, target.z)) return; + this.blockManager.addBlock(target.x, target.y, target.z, this._activeBlockType); + this._lastPlacedKey = key; + } else if (tool === 'erase') { + if (pick.mesh?.metadata?.isBlock) { + const m = pick.mesh.metadata; + const key = `${m.gridX},${m.gridY},${m.gridZ}`; + if (key === this._lastPlacedKey) return; + this.blockManager.removeBlockByMesh(pick.mesh); + this._lastPlacedKey = key; + } + } + } + + /** + * Обновить гизмо под текущее выделение. + */ + _updateGizmoForSelection(sel) { + if (!this._gizmo) return; + if (!sel) { + this._gizmo.attachTo(null); + return; + } + if (sel.type === 'block') { + this._gizmo.attachTo(sel.mesh); + } else if (sel.type === 'model' || sel.type === 'spawn' + || sel.type === 'userModel') { + this._gizmo.attachTo(sel.rootMesh); + } else if (sel.type === 'primitive') { + this._gizmo.attachTo(sel.mesh); + } + // Переустанавливаем режим чтобы sub-gizmo (move/rotate/scale) + // гарантированно пересоздалась поверх нового attached-mesh. + // Без этого гизмо иногда оказывается привязанным к старому или null + // объекту и стрелки становятся «неактивными». + this._gizmo.refreshMode(); + } + + /** + * Гизмо манипулировал объектом — синхронизируем через SelectionManager. + * Тип операции (move/rotate/scale) определяется по режиму гизмо. + */ + _onGizmoDragEnd() { + if (!this.selection || !this._gizmo) return; + const sel = this.selection.getSelection(); + if (!sel) return; + const mode = this._gizmo.getMode(); + + if (sel.type === 'block') { + if (mode === 'move') { + // Babylon-mesh.position — центр блока (gridX, gridY+0.5, gridZ) + const newX = Math.round(sel.mesh.position.x); + const newY = Math.round(sel.mesh.position.y - 0.5); + const newZ = Math.round(sel.mesh.position.z); + if (newX === sel.gridX && newY === sel.gridY && newZ === sel.gridZ) { + sel.mesh.position.set(sel.gridX, sel.gridY + 0.5, sel.gridZ); + return; + } + if (this.blockManager.hasBlock(newX, newY, newZ)) { + sel.mesh.position.set(sel.gridX, sel.gridY + 0.5, sel.gridZ); + return; + } + this.selection.moveSelectedBlock(newX, newY, newZ); + } + // Блоки не поворачиваем и не масштабируем (по дизайну voxel-сцены). + // Если пользователь дёрнул rotate/scale — игнорируем. + } else if (sel.type === 'model') { + const root = sel.rootMesh; + if (mode === 'move') { + this.selection.moveSelectedModel(root.position.x, root.position.y, root.position.z); + } else if (mode === 'rotate') { + this.selection.rotateSelectedModel(root.rotation.y); + } else if (mode === 'scale') { + // Берём средний масштаб (для равномерного скейла) + const avg = (root.scaling.x + root.scaling.y + root.scaling.z) / 3; + this.selection.scaleSelectedModel(avg); + } + } else if (sel.type === 'userModel') { + const root = sel.rootMesh; + if (mode === 'move') { + this.selection.moveSelectedUserModel(root.position.x, root.position.y, root.position.z); + } else if (mode === 'rotate') { + this.selection.rotateSelectedUserModel(root.rotation.y); + } else if (mode === 'scale') { + const avg = (root.scaling.x + root.scaling.y + root.scaling.z) / 3; + this.selection.scaleSelectedUserModel(avg); + } + } else if (sel.type === 'spawn') { + const root = sel.rootMesh; + if (mode === 'move') { + this.selection.moveSelectedSpawn(root.position.x, root.position.y, root.position.z); + } + } else if (sel.type === 'primitive') { + const root = sel.mesh; + if (mode === 'move') { + this.selection.moveSelectedPrimitive(root.position.x, root.position.y, root.position.z); + } else if (mode === 'rotate') { + // Сохраняем поворот в data → попадёт в serialize при save. + this.primitiveManager?.updateInstance(sel.id, { + rotationX: root.rotation.x, + rotationY: root.rotation.y, + rotationZ: root.rotation.z, + }); + } else if (mode === 'scale') { + // Снимаем scaling до пересоздания (после _recreateMesh старый mesh dispose'ится). + const newSx = sel.sx * root.scaling.x; + const newSy = sel.sy * root.scaling.y; + const newSz = sel.sz * root.scaling.z; + root.scaling.set(1, 1, 1); + this.selection.resizeSelectedPrimitive(newSx, newSy, newSz); + // resizeSelectedPrimitive уже обновил sel.mesh на новый и + // вызвал _highlightMesh. Перепривязываем гизмо к новому mesh. + const updatedSel = this.selection.getSelection(); + if (updatedSel?.mesh) { + this._gizmo.attachTo(updatedSel.mesh); + } + } + } + } + + /** Публичный сеттер: переключить инструмент извне (из React-компонента). */ + setActiveTool(toolName) { + this._activeTool = toolName; + if (this._ghostMesh) { + this._ghostMesh.setEnabled(toolName === 'block'); + } + // Preview-кисть террейна. Если меш ещё не создан — создадим лениво + // при первом setTerrainBrush (это произойдёт в TerrainPanel useEffect). + if (toolName === 'terrain') { + if (!this._terrainBrushPreview && this._terrainBrush) { + this._updateTerrainBrushPreview(); + } + if (this._terrainBrushPreview) { + this._terrainBrushPreview.setEnabled(true); + } + } else if (this._terrainBrushPreview) { + this._terrainBrushPreview.setEnabled(false); + } + } + + /** + * Обновить состояние кисти ландшафта из TerrainPanel. + * Принимает частичный объект — то что не задано, не меняется. + */ + setTerrainBrush(patch) { + if (!this._terrainBrush) return; + const prevTool = this._terrainBrush.tool; + Object.assign(this._terrainBrush, patch || {}); + this._updateTerrainBrushPreview(); + // Инструмент «Выбрать деко»: включаем пикинг thin-instance декораций, + // при уходе с инструмента — выключаем и снимаем подсветку. + const nowPick = this._terrainBrush.tool === 'pickDeco'; + const wasPick = prevTool === 'pickDeco'; + if (nowPick !== wasPick) { + if (this._smoothDecoManager?.setPickingEnabled) { + this._smoothDecoManager.setPickingEnabled(nowPick); + } + if (!nowPick) this._clearDecoSelection(); + } + } + + /** Снять подсветку выбранной декорации (маркер-сфера). */ + _clearDecoSelection() { + if (this._decoSelMarker) { + try { this._decoSelMarker.dispose(); } catch (e) {} + this._decoSelMarker = null; + } + this._decoSelection = null; + } + + /** + * Клик инструментом «Выбрать деко»: raycast по thin-instance декорациям, + * подсветка выбранного дерева/куста маркером. Удаление — по Del + * (обрабатывается в _deleteSelectedDeco). + */ + _pickDecoTick() { + const dm = this._smoothDecoManager; + if (!dm) return; + const pick = this.scene.pick(this.scene.pointerX, this.scene.pointerY, + (m) => m.isPickable && m.name && m.name.startsWith('__smoothDeco_')); + if (!pick || !pick.hit || !pick.pickedMesh) { + this._clearDecoSelection(); + return; + } + const thinIdx = pick.thinInstanceIndex; + const found = dm.findInstanceByPick(pick.pickedMesh, thinIdx); + if (!found) { + this._clearDecoSelection(); + return; + } + this._decoSelection = found; + // Маркер-подсветка: жёлтая полупрозрачная сфера над выбранным деко. + if (this._decoSelMarker) { + try { this._decoSelMarker.dispose(); } catch (e) {} + } + const marker = MeshBuilder.CreateSphere('__decoSelMarker', { diameter: 3, segments: 10 }, this.scene); + const mat = new StandardMaterial('__decoSelMarkerMat', this.scene); + mat.emissiveColor = new Color3(1, 0.85, 0.1); + mat.alpha = 0.35; + mat.disableLighting = true; + marker.material = mat; + marker.isPickable = false; + marker.position.set(found.x, found.y + 2, found.z); + this._decoSelMarker = marker; + console.log(`[pickDeco] выбрана ${found.decoKey} @ (${found.x.toFixed(1)},${found.z.toFixed(1)})`); + } + + /** Удалить выбранную инструментом «Выбрать деко» декорацию (вызов по Del). */ + _deleteSelectedDeco() { + if (!this._decoSelection || !this._smoothDecoManager) return false; + const { decoKey, fullIndex } = this._decoSelection; + const ok = this._smoothDecoManager.removeInstanceAt(decoKey, fullIndex); + if (ok) { + // Пересинхронизировать tree-collider'ы (вдруг удалили дерево) + if (this.physics?.setSmoothDecoTrees) { + this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders()); + } + this._clearDecoSelection(); + try { this._onSceneChange?.(); } catch (e) {} + } + return ok; + } + + /** + * Обновить geometry preview-меша кисти (полупрозрачная сфера/куб + * по форме кисти, размер 2*radius). Цвет берётся из текущего материала. + */ + _updateTerrainBrushPreview() { + if (!this._terrainBrush) return; + const { brushSize, shape, material } = this._terrainBrush; + const r = Math.max(1, brushSize); + // Берём цвет напрямую из палитры — стабильный hex, не зависит от того + // загружена ли уже текстура. Превью кисти просто тонируется в основной + // цвет выбранного материала. + // eslint-disable-next-line global-require + const { Color3 } = require('@babylonjs/core'); + let matCol = null; + try { + const def = TERRAIN_MATERIAL_DEFS?.[material]; + if (def?.color) matCol = Color3.FromHexString(def.color); + } catch (e) {} + + // Удаляем старый preview если форма/размер/режим изменились + if (this._terrainBrushPreview) { + const md = this._terrainBrushPreview.metadata || {}; + const curMode = this._terrainBrush?.terrainMode || 'voxel'; + if (md.shape !== shape || md.radius !== r || md.terrainMode !== curMode) { + try { this._terrainBrushPreview.dispose(); } catch (e) {} + this._terrainBrushPreview = null; + } + } + + if (!this._terrainBrushPreview) { + // eslint-disable-next-line global-require + const { MeshBuilder, StandardMaterial, Color3 } = require('@babylonjs/core'); + // Размер кисти в МИРОВЫХ единицах: + // voxel-режим: r (voxel) × VOXEL_SIZE × 2 + 1 voxel (центр) + // smooth-режим: r — это РАДИУС В МЕТРАХ, диаметр = r*2 + const isSmooth = this._terrainBrush?.terrainMode === 'smooth'; + const worldDiameter = isSmooth + ? r * 2 + : (r * 2 + 1) * TERRAIN_VOXEL_SIZE; + let mesh; + if (shape === 'cube') { + mesh = MeshBuilder.CreateBox('__terrainBrushPreview', { size: worldDiameter }, this.scene); + } else if (shape === 'cylinder') { + mesh = MeshBuilder.CreateCylinder('__terrainBrushPreview', { height: worldDiameter, diameter: worldDiameter }, this.scene); + } else { + mesh = MeshBuilder.CreateSphere('__terrainBrushPreview', { diameter: worldDiameter, segments: 16 }, this.scene); + } + const mat = new StandardMaterial('__terrainBrushPreviewMat', this.scene); + mat.emissiveColor = matCol || new Color3(0.2, 0.7, 0.3); + mat.diffuseColor = new Color3(0, 0, 0); + mat.alpha = 0.22; + mat.disableLighting = false; + mat.backFaceCulling = false; + mesh.material = mat; + mesh.isPickable = false; + mesh.metadata = { + _isTerrainBrushPreview: true, + shape, radius: r, + terrainMode: this._terrainBrush?.terrainMode || 'voxel', + _baseAlpha: 0.22, + _activeAlpha: 0.45, + }; + mesh.setEnabled(this._activeTool === 'terrain'); + this._terrainBrushPreview = mesh; + } else { + // Обновляем цвет + if (matCol) { + try { this._terrainBrushPreview.material.emissiveColor = matCol; } catch (e) {} + } + } + } + + /** + * Вычислить точку клика для кисти террейна. Возвращает {x, y, z} в + * voxel-координатах (целые) или null. + * + * Логика: + * 1. Если попали по существующему voxel'у — берём клетку или соседнюю + * по нормали (в зависимости от инструмента). + * 2. Иначе делаем raycast на плоскость y=0 (пол сцены) — это даёт + * площадку «где начнётся ландшафт». + */ + _pickTerrainCell(forNewVoxel) { + const pi = this.scene.pick( + this.scene.pointerX, + this.scene.pointerY, + (mesh) => { + if (!mesh.isPickable) return false; + if (mesh === this._ghostMesh) return false; + if (mesh === this._terrainBrushPreview) return false; + if (mesh.metadata?._isBlockProto) return false; + if (mesh.metadata?._isTerrainProto) return false; + if (mesh.name && mesh.name.startsWith('gridLine')) return false; + return true; + } + ); + + // Сначала — попытка raycast по существующему террейну (DDA) + const camera = this.scene.activeCamera; + const ray = this.scene.createPickingRay( + this.scene.pointerX, + this.scene.pointerY, + null, + camera, + false, + ); + const tHit = this.terrainManager?.pickVoxelByRay(ray.origin, ray.direction, 200); + if (tHit) { + if (forNewVoxel) { + return { + x: tHit.cell.x + tHit.normal.x, + y: tHit.cell.y + tHit.normal.y, + z: tHit.cell.z + tHit.normal.z, + }; + } + return tHit.cell; + } + + // Иначе — попадание по обычным мешам (пол/блок). Делим мировые + // координаты на TERRAIN_VOXEL_SIZE чтобы получить voxel-индекс. + if (pi?.hit && pi.pickedPoint) { + const p = pi.pickedPoint; + const n = pi.getNormal?.(true) || { x: 0, y: 1, z: 0 }; + // Идём на 0.001 внутрь по противоположной нормали — чтобы координата + // упала в правильный voxel. + const inside = { + x: p.x - n.x * 0.001, + y: p.y - n.y * 0.001, + z: p.z - n.z * 0.001, + }; + const S = TERRAIN_VOXEL_SIZE; + const baseX = Math.floor(inside.x / S); + const baseY = Math.floor(inside.y / S); + const baseZ = Math.floor(inside.z / S); + if (forNewVoxel) { + return { + x: baseX + Math.round(n.x), + y: baseY + Math.round(n.y), + z: baseZ + Math.round(n.z), + }; + } + return { x: baseX, y: baseY, z: baseZ }; + } + + // Fallback — raycast на плоскость y=0. Преобразуем мировые + // координаты в voxel-индексы делением на TERRAIN_VOXEL_SIZE. + if (Math.abs(ray.direction.y) > 1e-4) { + const t = -ray.origin.y / ray.direction.y; + if (t > 0 && t < 200) { + const hx = ray.origin.x + ray.direction.x * t; + const hz = ray.origin.z + ray.direction.z * t; + const S = TERRAIN_VOXEL_SIZE; + const cx = Math.floor(hx / S); + const cz = Math.floor(hz / S); + // Прилипание к верху столбца: если в этом столбце уже + // есть voxel'ы террейна, используем Y верхнего + 1. + // Это убирает «прыжок» кисти когда юзер начинает рисовать + // на пол рядом с уже существующим холмом — она остаётся + // на уровне его поверхности. + let topY = -1; + if (this.terrainManager) { + const found = this.terrainManager._findTopY?.(cx, cz, 200, -50); + if (found !== null && found !== undefined) topY = found; + } + return { + x: cx, + y: forNewVoxel && topY >= 0 ? topY + 1 : (topY >= 0 ? topY : 0), + z: cz, + }; + } + } + return null; + } + + /** + * Один «тик» кисти террейна. Вызывается при mousedown и mousemove с зажатой + * ЛКМ когда activeTool === 'terrain'. + * + * shiftKey — модификатор «обратное действие» (стереть/опустить). + */ + _terrainBrushTick(shiftKey, isFirst) { + if (this._isPlaying) return; + if (this._activeTool !== 'terrain') return; + + // === Smooth-режим: редактируем DensityGrid через SmoothBrushes === + if (this._terrainBrush?.terrainMode === 'smooth') { + this._smoothBrushTick(shiftKey, isFirst); + return; + } + + if (!this.terrainManager) return; + const tool = this._terrainBrush?.tool || 'draw'; + + // === Rate-limit voxel-кисти === + // mousemove приходит ~100Hz, каждый тик brushDraw/sculpt с radius=16 + // = ~17000 thinInstanceAdd. Даже с GPU-batch это съедает кадр. + // Ограничиваем тики до ~25 Hz (40ms) — кисть всё равно плавно + // покрывает поверхность за счёт drag по экрану. + if (!isFirst) { + const now = performance.now(); + const last = this._voxelBrushLastTick || 0; + const radius = this._terrainBrush?.brushSize || 4; + // Чем больше кисть, тем реже тики (защита от лагов). + const minInterval = radius <= 4 ? 30 : radius <= 8 ? 50 : radius <= 16 ? 80 : 120; + if (now - last < minInterval) return; + this._voxelBrushLastTick = now; + } else { + this._voxelBrushLastTick = performance.now(); + } + + // === Plant-кисти voxel-режима: размещение мини-воксельных моделей === + // plantGrass / plantFlower / plantMushroom / plantTree. + // Shift = стереть декорации в зоне. + if (tool === 'plantGrass' || tool === 'plantFlower' + || tool === 'plantMushroom' || tool === 'plantTree') { + // При новом клике сбрасываем rate-limit pos, чтобы первый клик + // в той же точке всегда срабатывал. + if (isFirst) { + this._voxelTreeLastPos = null; + this._voxelPlantLastPos = null; + } + const cell = this._pickTerrainCell(true); + if (!cell) return; + const brush = { + x: cell.x, y: cell.y, z: cell.z, + radius: this._terrainBrush.brushSize || 4, + shape: this._terrainBrush.shape || 'sphere', + strength: this._terrainBrush.strength ?? 50, + }; + if (shiftKey) { + this._eraseDecorationsInBrush(brush); + } else { + this._placeVoxelPlantsAtBrush(brush, tool); + } + return; + } + + // Для перекраски/выровнять — берём клетку с поверхности (не «над») + // Для рисования — клетку «над» (по нормали) + const wantsAdjacent = (tool === 'draw' || tool === 'sculpt'); + const cell = this._pickTerrainCell(wantsAdjacent); + if (!cell) return; + + // На скульпте и выровнять — фиксируем Y первой точки, чтобы при drag + // не перепрыгивать на разные слои каждым движением мыши. + if (isFirst) { + this._terrainDragLockY = cell.y; + } + + const brush = { + x: cell.x, + y: (tool === 'flatten' || tool === 'sculpt' || tool === 'smooth') + ? this._terrainDragLockY ?? cell.y + : cell.y, + z: cell.z, + radius: this._terrainBrush.brushSize || 4, + shape: this._terrainBrush.shape || 'sphere', + }; + const matId = this._terrainBrush.material || 'grass'; + const strength = this._terrainBrush.strength ?? 50; + + if (shiftKey) { + // Shift = обратная кисть. Для sculpt — опускает (Sculpt Down). + // Для всех остальных — стирает (voxels + декорации в зоне). + if (tool === 'sculpt') { + this.terrainManager.brushSculpt(brush, -1, matId, strength); + } else { + this.terrainManager.brushErase(brush); + this._eraseDecorationsInBrush(brush); + } + return; + } + + // === Деко-материалы → мини-воксельные модели === + // Если выбран материал-декорация (трава/цветы/грибы/листья) — кисть + // ставит МОДЕЛЬ из DecoModels, а не плоский voxel. Это для tool=draw, + // sculpt (рисование) — где пользователь "красит" декорациями. + // smooth/paint/flatten — стандартный voxel-rendering. + if (this._isDecoMaterial(matId) && (tool === 'draw' || tool === 'sculpt')) { + this._placeDecoModelsAtBrush(brush, matId); + return; + } + + // Если выбран деко-материал но инструмент НЕ ставит модели (smooth/paint/flatten), + // нельзя засыпать столбцы декорациями — fallback на 'grass'. + const safeMatId = this._isDecoMaterial(matId) ? 'grass' : matId; + + switch (tool) { + case 'draw': + this.terrainManager.brushDraw(brush, matId); + break; + case 'sculpt': + this.terrainManager.brushSculpt(brush, +1, matId, strength); + break; + case 'smooth': + // Smooth работает БЕЗ выбранного материала: засыпка идёт + // тем материалом, что уже есть у соседних solid voxels. + this.terrainManager.brushSmooth(brush, null); + break; + case 'paint': + this.terrainManager.brushPaint(brush, safeMatId); + break; + case 'flatten': + this.terrainManager.brushFlatten(brush, safeMatId); + break; + case 'erase': + // Стираем И voxels И декорации в зоне кисти. + this.terrainManager.brushErase(brush); + this._eraseDecorationsInBrush(brush); + break; + default: + break; + } + } + + /** Переключение «активного» состояния preview-меша кисти террейна. + * active=true делает кисть ярче (alpha 0.45 vs 0.22) — пока зажата ЛКМ. */ + _setTerrainBrushPreviewActive(active) { + const m = this._terrainBrushPreview; + if (!m || !m.material) return; + try { + const meta = m.metadata || {}; + m.material.alpha = active ? (meta._activeAlpha || 0.45) : (meta._baseAlpha || 0.22); + } catch (e) {} + } + + // ======================================================================== + // Undo / Redo для террейна + // + // Хранится стек снапшотов всего террейна (массив serialize'данных). + // Один drag-мазок кистью = один снапшот. История ограничена 30 шагами + // (чтобы не съесть RAM при больших террейнах). + // + // Использование: + // _terrainHistoryOpen() — перед началом мазка (mousedown по террейну) + // _terrainHistoryClose() — после конца мазка (mouseup): если что-то + // изменилось, фиксируем «открытый» снапшот + // undoTerrain() / redoTerrain() — горячие клавиши Ctrl+Z / Ctrl+Y + // ======================================================================== + + /** + * Маппинг "деко-материалов" (выбираемых в палитре voxel-режима) на + * `modelId` из DECO_MODELS. Если material есть в этой карте — кисть + * ставит МОДЕЛЬ через DecoManager, а не плоский voxel. + */ + _decoMaterialToModels(matId) { + switch (matId) { + case 'tall_grass': + // Случайная модель травы из pool — каждый клик ставит разные + return GRASS_MODELS_POOL; + case 'flower_red': return ['poppy']; + case 'flower_blue': return ['cornflower']; + case 'flower_yellow': return ['dandelion', 'daisy']; + case 'mushroom_red': return ['fly_mushroom']; + // Эти материалы можно тоже спрятать под деко если хочется, + // но пока оставляем как voxels (они нужны для деревьев и т.п.) + // case 'leaves': case 'leaves_orange': case 'rock_moss': case 'trunk': + default: return null; + } + } + + /** True если material — декорация (ставится моделью). */ + _isDecoMaterial(matId) { + return this._decoMaterialToModels(matId) !== null; + } + + /** + * Поставить мини-воксельные модели в зоне кисти (sphere/cube). + * Плотность — 30% точек grid в радиусе → штук 5-15 на клик. + */ + _placeDecoModelsAtBrush(brush, matId) { + if (!this.decoManager) return; + const models = this._decoMaterialToModels(matId); + if (!models || models.length === 0) return; + const TERRAIN_VOXEL = 0.25; + const r = brush.radius; + // brush.x/y/z в voxel-индексах террейна (cells 0.25м). + // Мировые координаты центра brush в МЕТРАХ. + const cx = (brush.x + 0.5) * TERRAIN_VOXEL; + const cz = (brush.z + 0.5) * TERRAIN_VOXEL; + // Top-surface Y: ищем верх solid voxels под brush.x,brush.z. + // Если на этой колонне нет voxel — ставим прямо на baseplate y=0. + let topVoxY = brush.y; + // count = ~ 8 случайных позиций + const COUNT = 10; + const placedKeys = new Set(); + for (let i = 0; i < COUNT; i++) { + // Случайная точка в круге radius (в voxel-units) + const angle = Math.random() * Math.PI * 2; + const rr = Math.sqrt(Math.random()) * r; + const vx = brush.x + Math.cos(angle) * rr; + const vz = brush.z + Math.sin(angle) * rr; + const worldX = (vx + 0.5) * TERRAIN_VOXEL; + const worldZ = (vz + 0.5) * TERRAIN_VOXEL; + // Y: top surface — используем brush.y + 1 voxel (над поверхностью) + const worldY = (topVoxY + 1) * TERRAIN_VOXEL; + // Защита от дублирования: не ставим 2 модели в одну сетку 0.5м + const key = `${Math.floor(worldX*2)},${Math.floor(worldZ*2)}`; + if (placedKeys.has(key)) continue; + placedKeys.add(key); + // Случайная модель из набора + const modelId = models[Math.floor(Math.random() * models.length)]; + const rotation = Math.random() * Math.PI * 2; + const scale = 0.9 + Math.random() * 0.3; + this.decoManager.placeModel(worldX, worldY, worldZ, modelId, rotation, scale); + } + try { this._onSceneChange?.(); } catch (e) {} + } + + /** + * Поставить декорации plant-кистью voxel-режима по типу инструмента. + * tool: plantGrass | plantFlower | plantMushroom | plantTree. + * Trees — из voxel-блоков (trunk + leaves), остальные — мини-модели. + */ + _placeVoxelPlantsAtBrush(brush, tool) { + if (tool === 'plantTree') { + this._placeVoxelTreesAtBrush(brush); + return; + } + // Подбираем пул моделей по типу инструмента + let models; + let countMul; // множитель плотности на тик (как в smooth: grass=густо, flower=средне) + switch (tool) { + case 'plantGrass': + models = GRASS_MODELS_POOL; + countMul = 1.5; + break; + case 'plantFlower': + models = ['daisy', 'cornflower', 'poppy', 'dandelion']; + countMul = 1.0; + break; + case 'plantMushroom': + models = ['fly_mushroom', 'brown_mushroom']; + countMul = 0.5; + break; + default: + return; + } + if (!this.decoManager || !models || models.length === 0) return; + // Rate-limit между тиками: не ставим если кисть не сдвинулась. + const r = brush.radius; + const minDist = Math.max(1, r * 0.3); + const minDist2 = minDist * minDist; + if (this._voxelPlantLastPos) { + const dx = brush.x - this._voxelPlantLastPos.x; + const dz = brush.z - this._voxelPlantLastPos.z; + if (dx * dx + dz * dz < minDist2) return; + } + this._voxelPlantLastPos = { x: brush.x, z: brush.z }; + const TERRAIN_VOXEL = 0.25; + const topVoxY = brush.y; + // Кол-во точек пропорционально радиусу и типу декорации. + const COUNT = Math.max(2, Math.min(16, Math.round(r * countMul))); + const placedKeys = new Set(); + for (let i = 0; i < COUNT; i++) { + const angle = Math.random() * Math.PI * 2; + const rr = Math.sqrt(Math.random()) * r; + const vx = brush.x + Math.cos(angle) * rr; + const vz = brush.z + Math.sin(angle) * rr; + const worldX = (vx + 0.5) * TERRAIN_VOXEL; + const worldZ = (vz + 0.5) * TERRAIN_VOXEL; + const worldY = (topVoxY + 1) * TERRAIN_VOXEL; + const key = `${Math.floor(worldX*2)},${Math.floor(worldZ*2)}`; + if (placedKeys.has(key)) continue; + placedKeys.add(key); + const modelId = models[Math.floor(Math.random() * models.length)]; + const rotation = Math.random() * Math.PI * 2; + const scale = 0.9 + Math.random() * 0.3; + this.decoManager.placeModel(worldX, worldY, worldZ, modelId, rotation, scale); + } + try { this._onSceneChange?.(); } catch (e) {} + } + + /** + * Поставить ОДНО красивое процедурное дерево из voxel-блоков под кистью. + * + * Логика как в smooth-режиме (`_smoothBrushTickPlant`): + * - 1 дерево за тик (а не пучок) + * - rate-limit: если кисть не сдвинулась далеко, пропускаем тик + * - случайный выбор типа: oak / birch / autumn + * + * Использует `placeVoxelTree` из VoxelTreeBuilder.js (тот же алгоритм, + * который генерирует деревья в процедурном мире — толстый ствол, + * корни, ветви-зигзаги, главная крона + кроны на ветвях). + */ + _placeVoxelTreesAtBrush(brush) { + if (!this.terrainManager) return; + const tm = this.terrainManager; + + // === Rate-limit между тиками === + // Один тик = одно дерево. Если кисть не сдвинулась более чем на + // 0.4×radius (в voxel-units), пропускаем. Это убирает спам деревьев + // друг на друге при удержании ЛКМ. + const r = brush.radius; + const minDist = Math.max(2, r * 0.4); + const minDist2 = minDist * minDist; + if (this._voxelTreeLastPos) { + const dx = brush.x - this._voxelTreeLastPos.x; + const dz = brush.z - this._voxelTreeLastPos.z; + if (dx * dx + dz * dz < minDist2) return; + } + this._voxelTreeLastPos = { x: brush.x, z: brush.z }; + + // === Случайная точка в круге кисти (jitter) === + const angle = Math.random() * Math.PI * 2; + const rr = Math.sqrt(Math.random()) * r * 0.5; // не до края — деревья ближе к центру + const tx = Math.round(brush.x + Math.cos(angle) * rr); + const tz = Math.round(brush.z + Math.sin(angle) * rr); + + // === Top-surface для этой XZ === + const topY = tm._findTopY?.(tx, tz, brush.y + r * 4, brush.y - r * 4); + const baseY = (topY === null || topY === undefined) ? brush.y : topY; + + // === Размер дерева от strength (1..100) === + // strength=10 → саженец (sizeScale=0.5) + // strength=50 → стандарт (sizeScale=1.0) + // strength=100 → большое (sizeScale=2.0) + const strength = brush.strength ?? 50; + const sizeScale = 0.5 + (strength / 100) * 1.5; + + // === Случайный тип дерева === + const type = TREE_TYPES[Math.floor(Math.random() * TREE_TYPES.length)]; + + // === Уникальный seed на каждое дерево — даёт разную форму === + const seed = (Math.random() * 0x7fffffff) | 0; + + // === Ставим voxels через batched setVoxel-fn === + // tm._addInstance не обновляет GPU buffer в batch-режиме, делаем + // один flushBatch в конце. Это превращает ~300 add'ов в один upload. + tm._beginBatch?.(); + let placed = 0; + try { + const setVoxelFn = (x, y, z, matId) => { + const k = `${x},${y},${z}`; + if (tm.voxels.has(k)) return; + tm._addInstance?.(k, x, y, z, matId); + tm.voxels.set(k, matId); + placed++; + }; + placeVoxelTree(setVoxelFn, tx, baseY, tz, type, sizeScale, seed); + } finally { + tm._flushBatch?.(); + } + if (placed > 0) { + try { tm._emit?.(); } catch (e) {} + try { this._onSceneChange?.(); } catch (e) {} + } + } + + /** Удалить все декорации в зоне кисти (по миру). */ + _eraseDecorationsInBrush(brush) { + if (!this.decoManager || !this.decoManager.placements) return; + const TERRAIN_VOXEL = 0.25; + const cx = (brush.x + 0.5) * TERRAIN_VOXEL; + const cz = (brush.z + 0.5) * TERRAIN_VOXEL; + const r = brush.radius * TERRAIN_VOXEL * 1.2; // чуть больше для удобства + const r2 = r * r; + const keep = []; + let removed = 0; + for (const p of this.decoManager.placements) { + const dx = p.x - cx; + const dz = p.z - cz; + if (dx * dx + dz * dz <= r2) { + removed++; + } else { + keep.push(p); + } + } + if (removed > 0) { + // Перезагружаем decoManager с обновлённым списком + this.decoManager.clear(); + this.decoManager.loadFromArray(keep); + try { this._onSceneChange?.(); } catch (e) {} + } + } + + _terrainHistoryEnsure() { + if (!this._terrainHistory) { + this._terrainHistory = { stack: [], cursor: -1, pending: null }; + } + return this._terrainHistory; + } + _terrainHistoryOpen() { + const tm = this.terrainManager; + if (!tm) return; + const h = this._terrainHistoryEnsure(); + // Снапшот до изменения + h.pending = tm.serialize(); + } + _terrainHistoryClose() { + const tm = this.terrainManager; + if (!tm) return; + const h = this._terrainHistoryEnsure(); + if (!h.pending) return; + const after = tm.serialize(); + // Сравниваем размером — если ничего не изменилось, не пушим + if (after.length === h.pending.length && this._terrainSerEqual(after, h.pending)) { + h.pending = null; + return; + } + // Обрубаем все «впереди-курсора» (redo-стек после нового действия) + if (h.cursor < h.stack.length - 1) { + h.stack.length = h.cursor + 1; + } + h.stack.push(h.pending); + h.cursor = h.stack.length - 1; + // Ограничение 30 шагов + const MAX = 30; + while (h.stack.length > MAX) { + h.stack.shift(); + h.cursor--; + } + h.pending = null; + } + _terrainSerEqual(a, b) { + // Сравнение двух serialize-массивов. O(n) с использованием Set. + if (a.length !== b.length) return false; + const sa = new Set(); + for (const v of a) sa.add(`${v.x},${v.y},${v.z},${v.m}`); + for (const v of b) if (!sa.has(`${v.x},${v.y},${v.z},${v.m}`)) return false; + return true; + } + undoTerrain() { + const tm = this.terrainManager; + if (!tm) return false; + const h = this._terrainHistoryEnsure(); + if (h.cursor < 0) return false; + // Текущий стейт в редо-позицию (cursor+1) + const current = tm.serialize(); + const target = h.stack[h.cursor]; + // Если на cursor лежит снапшот «до», нам нужно вернуться к нему. + // Cursor указывает на последнее ВОЗВРАТНОЕ состояние. + tm.loadFromArray(target); + // Записываем текущий стейт в позицию cursor+1 для возможного redo + h.stack[h.cursor + 1] = current; + h.cursor--; + return true; + } + redoTerrain() { + const tm = this.terrainManager; + if (!tm) return false; + const h = this._terrainHistoryEnsure(); + if (h.cursor + 1 >= h.stack.length - 0) return false; + const target = h.stack[h.cursor + 2]; + if (!target) return false; + tm.loadFromArray(target); + h.cursor++; + return true; + } + + // ======================================================================== + // Регион террейна (инструменты «Выделить», «Заполнить», «Преобразовать») + // + // Регион — это объёмная коробка, заданная двумя углами в voxel-индексах. + // Визуализируется wireframe-боксом. Создаётся drag-rectangle'ом на земле: + // первый mousedown в режиме «Выделить» — стартовый угол, drag — второй. + // Высота коробки фиксируется ±radius по Y вокруг плоскости клика. + // + // Регион используется: + // • «Заполнить» — залить регион выбранным материалом + // • «Преобразовать» — переместить все voxel'ы региона в новое место + // (плоский drag по XZ, без поворота на этапе 2). + // ======================================================================== + + /** Текущее выделение или null. Структура: {x0,y0,z0,x1,y1,z1} (включительно). */ + getTerrainRegion() { return this._terrainRegion || null; } + + /** Очистить выделение и убрать визуализацию. */ + clearTerrainRegion() { + this._terrainRegion = null; + if (this._terrainRegionMesh) { + try { this._terrainRegionMesh.dispose(); } catch (e) {} + this._terrainRegionMesh = null; + } + } + + /** Обновить wireframe-визуализацию региона по this._terrainRegion. */ + _updateTerrainRegionVisual() { + const r = this._terrainRegion; + if (this._terrainRegionMesh) { + try { this._terrainRegionMesh.dispose(); } catch (e) {} + this._terrainRegionMesh = null; + } + if (!r) return; + + // eslint-disable-next-line global-require + const { MeshBuilder, StandardMaterial, Color3 } = require('@babylonjs/core'); + const S = TERRAIN_VOXEL_SIZE; + const minX = Math.min(r.x0, r.x1); + const maxX = Math.max(r.x0, r.x1); + const minY = Math.min(r.y0, r.y1); + const maxY = Math.max(r.y0, r.y1); + const minZ = Math.min(r.z0, r.z1); + const maxZ = Math.max(r.z0, r.z1); + // Размер в мире — кол-во клеток × VOXEL_SIZE. +1 потому что включительно. + const sizeX = (maxX - minX + 1) * S; + const sizeY = (maxY - minY + 1) * S; + const sizeZ = (maxZ - minZ + 1) * S; + const cx = (minX + 0.5) * S + (sizeX - S) / 2; + const cy = (minY + 0.5) * S + (sizeY - S) / 2; + const cz = (minZ + 0.5) * S + (sizeZ - S) / 2; + + const mesh = MeshBuilder.CreateBox('__terrainRegion', { + width: sizeX, height: sizeY, depth: sizeZ, + }, this.scene); + mesh.position.set(cx, cy, cz); + mesh.isPickable = false; + const mat = new StandardMaterial('__terrainRegionMat', this.scene); + mat.wireframe = true; + mat.emissiveColor = new Color3(0.20, 0.55, 1.00); + mat.diffuseColor = new Color3(0, 0, 0); + mat.alpha = 0.9; + mesh.material = mat; + mesh.metadata = { _isTerrainRegion: true }; + this._terrainRegionMesh = mesh; + } + + /** Запустить выделение региона: pickStart — voxel-клетка начала. */ + _terrainBeginRegion(pickStart) { + const radius = this._terrainBrush?.brushSize || 4; + // Высота региона по умолчанию = ±radius от Y клика. При drag юзер + // может уточнить — но через первый MVP оставим фиксированной. + this._terrainRegion = { + x0: pickStart.x, y0: Math.max(0, pickStart.y - radius), z0: pickStart.z, + x1: pickStart.x, y1: pickStart.y + radius, z1: pickStart.z, + }; + this._terrainRegionDragging = true; + this._updateTerrainRegionVisual(); + } + + /** Обновить второй угол региона по новой voxel-клетке. */ + _terrainUpdateRegion(pickEnd) { + if (!this._terrainRegion) return; + this._terrainRegion.x1 = pickEnd.x; + this._terrainRegion.z1 = pickEnd.z; + // Y оставляем как поставили при начале — drag по плоскости XZ + this._updateTerrainRegionVisual(); + } + + /** Завершить drag выделения. */ + _terrainEndRegion() { + this._terrainRegionDragging = false; + } + + /** + * Инициализировать пустой smooth-terrain для скульптинга с нуля. + * Создаёт DensityGrid 100×24×100 cells (400×96×400м) с density=0 везде. + * Первый клик sculpt-кистью сразу породит холм в нужном месте. + */ + _initEmptySmoothTerrain() { + if (this._robloxTerrain) { + try { this._robloxTerrain.disposeAll(); } catch (e) {} + } + this._robloxTerrain = new RobloxTerrain(this.scene); + if (this.physics?.setRobloxTerrain) { + this.physics.setRobloxTerrain(this._robloxTerrain); + } + const sx = 100, sy = 24, sz = 100; + const grid = new RobloxDensityGrid({ + origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 }, + size: { x: sx, y: sy, z: sz }, + }); + // Регистрируем стандартные материалы в палитре (нужно для brushes). + // Index 0 = пусто, далее по порядку для совместимости с web/Android. + for (const matKey of ['grass', 'rock', 'sand', 'snow', 'dirt']) { + // Hack: set одной ячейки потом обнуляем, чтобы добавить в palette. + grid.set(0, 0, 0, 0, matKey); + } + // Сбрасываем (0,0,0) обратно в пусто — но matData[0] остался matId + // последнего set'а. Обнуляем явно. + grid.densityData[0] = 0; + grid.matData[0] = 0; + // skipEmpty: true — НЕ добавляем 98 пустых chunks в pending, + // mesher будет работать только после первой кисти. + this._robloxTerrain.loadFromGrid(grid, { skipEmpty: true }); + // НЕ отключаем baseplate сразу — нужен чтобы raycast мог пикать + // плоскость y=0 при первых кликах. Отключим когда появится хоть один + // solid chunk (см. updateStreaming / applyBrushAndRebuild). + console.log('[BabylonScene] _initEmptySmoothTerrain: 100×24×100 grid created (skipEmpty=true)'); + } + + /** + * Тик smooth-кисти. Делает raycast по mesh-ам smooth-terrain, + * получает worldPosition и вызывает applyBrush в этой точке. + * + * Если smooth-terrain ещё не создан (свежий проект, нажимаем sculpt + * на пустой сцене) — создаём пустой DensityGrid 100×24×100 cells + * (400×96×400м) при первом клике sculpt/fill, как в Roblox Studio. + */ + _smoothBrushTick(shiftKey, isFirst) { + const tool = this._terrainBrush?.tool || 'sculpt'; + // === Инструмент «Выбрать деко» — поштучный клик-выбор декораций === + // Не модифицирует ландшафт, работает только на клик (не drag). + if (tool === 'pickDeco') { + if (isFirst) this._pickDecoTick(); + return; + } + const terrainEmpty = !this._robloxTerrain || !this._robloxTerrain.grid; + + // === Счётчики для диагностики === + if (!this._smoothBrushDiag) { + this._smoothBrushDiag = { + tickCount: 0, hitTerrain: 0, hitGround: 0, hitNone: 0, + applyResult: { built: 0, dirty0: 0 }, + }; + } + const D = this._smoothBrushDiag; + D.tickCount++; + const tickN = D.tickCount; + + // === Plane-lock + rate-limit (как в Roblox Studio) === + // При isFirst — фиксируем плоскость и позицию первого клика. + // Дальше при drag: + // 1) center.y берётся не из raycast (он растёт вверх вслед за рельефом), + // а из зафиксированного _smoothBrushLockY → кисть работает в плоскости. + // 2) Между tick'ами требуется минимальное расстояние (0.6×radius) — + // иначе одна и та же точка → cells доходят до 255 → "какаха". + if (isFirst) { + this._smoothBrushLockY = null; + this._smoothBrushLastPos = null; + } + + // === Raycast — выбираем точку под курсором === + let hit = null; + let pickSource = ''; + if (!terrainEmpty) { + const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain); + hit = this.scene.pickWithRay( + this.scene.createPickingRay(this.scene.pointerX, this.scene.pointerY, null, this.camera), + pickPred, + ); + if (hit && hit.hit) { pickSource = 'terrain'; D.hitTerrain++; } + } + if (!hit || !hit.hit || !hit.pickedPoint) { + const groundPick = this.scene.pick(this.scene.pointerX, this.scene.pointerY, + (m) => m.name === 'editorGround'); + if (groundPick && groundPick.hit && groundPick.pickedPoint) { + hit = groundPick; + pickSource = 'ground'; + D.hitGround++; + } else { + D.hitNone++; + if (isFirst || tickN % 20 === 0) { + console.log(`[SmoothBrush] tick#${tickN} isFirst=${isFirst} → NO HIT (terrainEmpty=${terrainEmpty}, pointer=${this.scene.pointerX},${this.scene.pointerY})`); + } + return; + } + } + const worldPt = hit.pickedPoint; + + // === Инициализация пустого grid при первом клике sculpt/fill === + let initialized = false; + if (terrainEmpty) { + if (!isFirst) { + if (tickN <= 5 || tickN % 30 === 0) { + console.log(`[SmoothBrush] tick#${tickN} isFirst=false, terrainEmpty=true → skip (нужен первый клик)`); + } + return; + } + if (tool !== 'sculpt' && tool !== 'draw' && tool !== 'fill') { + console.log(`[SmoothBrush] tick#${tickN} terrainEmpty + tool='${tool}' → нельзя инициализировать (используйте sculpt/draw/fill)`); + return; + } + console.log(`[SmoothBrush] tick#${tickN} INIT empty grid (tool='${tool}', hit at ${worldPt.x.toFixed(1)},${worldPt.y.toFixed(1)},${worldPt.z.toFixed(1)})`); + this._initEmptySmoothTerrain(); + initialized = true; + if (!this._robloxTerrain || !this._robloxTerrain.grid) { + console.warn(`[SmoothBrush] tick#${tickN} init FAILED — grid still null`); + return; + } + } + + const material = this._terrainBrush?.material || 'grass'; + const radius = Math.max(3, (this._terrainBrush?.brushSize || 4) * 2.0); + // strength: slider 0..100 → реальная strength 60..400 для sculpt. + // Минимум 60 чтобы edge influence (~0.05) давал delta=3 → cells + // на краю кисти достигали threshold 128 за 40 тиков а не 128. + // Максимум 400 = мгновенно ставит cells в 255 (полная заливка). + const strengthSlider = this._terrainBrush?.strength ?? 50; + const strength = 60 + (strengthSlider / 100) * 340; + + let brushType; + if (shiftKey) { + brushType = 'sculptDown'; + } else { + switch (tool) { + case 'draw': brushType = 'sculptUp'; break; + case 'sculpt': brushType = 'sculptUp'; break; + case 'smooth': brushType = 'smooth'; break; + case 'paint': brushType = 'paint'; break; + case 'flatten': + if (isFirst) this._smoothFlattenTargetY = worldPt.y; + brushType = 'flatten'; + break; + case 'fill': brushType = 'fill'; break; + case 'erase': brushType = 'erase'; break; + // === Plant-кисти: добавление декораций === + case 'plantGrass': brushType = 'plantGrass'; break; + case 'plantFlower': brushType = 'plantFlower'; break; + case 'plantMushroom': brushType = 'plantMushroom'; break; + case 'plantTree': brushType = 'plantTree'; break; + default: brushType = 'sculptUp'; + } + } + + // === Plant-кисти обрабатываются ОТДЕЛЬНО от sculpt-логики === + // Они НЕ модифицируют DensityGrid — добавляют thin-instance модели. + if (brushType.startsWith('plant')) { + return this._smoothBrushTickPlant(brushType, worldPt, radius, shiftKey, isFirst); + } + + // === Plane-lock: для sculpt-кистей фиксируем Y первого клика === + // Без этого при drag в одной XZ-точке raycast возвращает всё более + // высокий Y (рельеф рос на прошлых тиках) → кисть смещается ВВЕРХ → + // рельеф летит на камеру ("какаха"). + // С lock'ом drag работает в горизонтальной плоскости фиксированной + // высоты первого клика, как в Roblox Studio. + const isSculptKind = brushType === 'sculptUp' || brushType === 'sculptDown'; + let centerY = worldPt.y; + if (isSculptKind) { + if (this._smoothBrushLockY === null) { + this._smoothBrushLockY = worldPt.y; + } + centerY = this._smoothBrushLockY; + // Смещение от плоскости первого клика на radius×0.5 + // (вверх для sculptUp, вниз для sculptDown). + if (brushType === 'sculptUp') centerY += radius * 0.5; + else centerY -= radius * 0.5; + } + + // === Rate-limit: пропускаем tick если кисть не сдвинулась далеко === + // Между тиками должно быть >= 0.6×radius по XZ. Это убивает feedback + // loop в одной точке. + if (isSculptKind && !isFirst && this._smoothBrushLastPos) { + const dx = worldPt.x - this._smoothBrushLastPos.x; + const dz = worldPt.z - this._smoothBrushLastPos.z; + const minDist = radius * 0.6; + if (dx * dx + dz * dz < minDist * minDist) { + // Кисть в той же точке — пропускаем (но НЕ для isFirst). + return; + } + } + if (isSculptKind) { + this._smoothBrushLastPos = { x: worldPt.x, z: worldPt.z }; + } + + const params = { + center: { x: worldPt.x, y: centerY, z: worldPt.z }, + radius, + strength, + material, + targetY: this._smoothFlattenTargetY, + }; + const tApply0 = performance.now(); + const built = this._robloxTerrain.applyBrushAndRebuild(brushType, params); + const tApply = performance.now() - tApply0; + if (built > 0) D.applyResult.built += built; + else D.applyResult.dirty0++; + + if (isFirst || tickN <= 5 || tickN % 20 === 0 || initialized || built === 0) { + const gridStats = this._robloxTerrain.grid + ? `solid=${this._robloxTerrain.grid.countSolid?.() ?? '?'}` + : 'no-grid'; + console.log( + `[SmoothBrush] tick#${tickN} ${brushType} slider=${strengthSlider} → strength=${strength.toFixed(0)} ` + + `pick='${pickSource}' @(${worldPt.x.toFixed(1)},${worldPt.y.toFixed(1)},${worldPt.z.toFixed(1)}) ` + + `r=${radius} mat=${material} ${gridStats} ` + + `→ built=${built} chunks in ${tApply.toFixed(0)}ms ` + + (initialized ? ' [INITIALIZED!]' : '') + + (built === 0 ? ' [NO CHANGE]' : ''), + ); + } + + try { this._onSceneChange?.(); } catch (e) {} + } + + /** + * Plant-кисть: расставляет/удаляет декорации (трава/цветы/грибы/деревья). + * Не трогает DensityGrid — работает только с SmoothDecoManager. + * Shift = ластик (удалить декорации в радиусе). + */ + _smoothBrushTickPlant(brushType, worldPt, radius, shiftKey, isFirst) { + // Нужен SmoothDecoManager (создаём lazy при первом plant-клике) + if (!this._smoothDecoManager) { + this._smoothDecoManager = new SmoothDecoManager(this.scene); + this._smoothDecoManager.loadAll(); + } + // Rate-limit как в sculpt: пропускаем близкие тики + if (!isFirst && this._smoothBrushLastPos) { + const dx = worldPt.x - this._smoothBrushLastPos.x; + const dz = worldPt.z - this._smoothBrushLastPos.z; + const minDist = radius * 0.4; // плотнее чем sculpt (декорации мелкие) + if (dx * dx + dz * dz < minDist * minDist) return; + } + this._smoothBrushLastPos = { x: worldPt.x, z: worldPt.z }; + + // Shift — ластик + if (shiftKey) { + const removed = this._smoothDecoManager.removeBrushDecoInRadius( + { x: worldPt.x, z: worldPt.z }, radius, + ); + if (removed > 0) { + console.log(`[SmoothBrush] erased ${removed} decorations at (${worldPt.x.toFixed(1)},${worldPt.z.toFixed(1)})`); + // Пересинхронизировать tree-colliders в physics + if (this.physics?.setSmoothDecoTrees) { + this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders()); + } + try { this._onSceneChange?.(); } catch (e) {} + } + return; + } + + // Маппинг brushType → kind для SmoothDecoManager + const kindMap = { + plantGrass: 'grass', + plantFlower: 'flower', + plantMushroom: 'mushroom', + plantTree: 'tree', + }; + const kind = kindMap[brushType]; + if (!kind) return; + + // Количество инстансов за один тик зависит от типа. + // Трава густая, деревья редко. + const countMap = { grass: 6, flower: 4, mushroom: 2, tree: 1 }; + const count = countMap[kind] || 3; + + // Surface-Y хелпер: raycast по smooth-terrain ИЛИ ground (y=0). + const sampleSurfaceY = (x, z) => { + if (this.physics?._sampleRobloxSurface) { + const y = this.physics._sampleRobloxSurface(x, z); + if (y !== null) return y; + } + // Fallback на ground y=0 + return 0; + }; + + const result = this._smoothDecoManager.addBrushDeco({ + kind, + center: { x: worldPt.x, y: worldPt.y, z: worldPt.z }, + radius, + count, + sampleSurfaceY, + }); + const added = result.added || 0; + if (added > 0) { + console.log(`[SmoothBrush] planted ${added} ${kind} at (${worldPt.x.toFixed(1)},${worldPt.z.toFixed(1)})`); + // Если посадили деревья — пересинхронизировать tree-colliders + // в physics (полная переустановка через getAllTreeColliders). + if (kind === 'tree' && this.physics?.setSmoothDecoTrees) { + this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders()); + } + try { this._onSceneChange?.(); } catch (e) {} + } + } + + /** Залить выделенный регион материалом. Используется инструментом + * «Заполнить» когда есть активный _terrainRegion. */ + _terrainFillRegion(matId) { + const r = this._terrainRegion; + const tm = this.terrainManager; + if (!r || !tm) return 0; + const minX = Math.min(r.x0, r.x1); + const maxX = Math.max(r.x0, r.x1); + const minY = Math.min(r.y0, r.y1); + const maxY = Math.max(r.y0, r.y1); + const minZ = Math.min(r.z0, r.z1); + const maxZ = Math.max(r.z0, r.z1); + let n = 0; + for (let x = minX; x <= maxX; x++) { + for (let y = minY; y <= maxY; y++) { + for (let z = minZ; z <= maxZ; z++) { + const key = `${x},${y},${z}`; + if (tm.voxels.has(key)) continue; + tm.setVoxel(x, y, z, matId); + n++; + } + } + } + return n; + } + + /** Переместить весь регион на dx/dy/dz в voxel-клетках. */ + _terrainMoveRegion(dx, dy, dz) { + const r = this._terrainRegion; + const tm = this.terrainManager; + if (!r || !tm) return 0; + if (dx === 0 && dy === 0 && dz === 0) return 0; + const minX = Math.min(r.x0, r.x1); + const maxX = Math.max(r.x0, r.x1); + const minY = Math.min(r.y0, r.y1); + const maxY = Math.max(r.y0, r.y1); + const minZ = Math.min(r.z0, r.z1); + const maxZ = Math.max(r.z0, r.z1); + // Собираем содержимое региона + const collected = []; + for (let x = minX; x <= maxX; x++) { + for (let y = minY; y <= maxY; y++) { + for (let z = minZ; z <= maxZ; z++) { + const m = tm.getVoxel(x, y, z); + if (!m) continue; + collected.push({ x, y, z, m }); + } + } + } + // Удаляем из старых позиций + for (const v of collected) tm.removeVoxel(v.x, v.y, v.z); + // Ставим в новые + let n = 0; + for (const v of collected) { + tm.setVoxel(v.x + dx, v.y + dy, v.z + dz, v.m); + n++; + } + // Сдвигаем сам регион + r.x0 += dx; r.x1 += dx; + r.y0 += dy; r.y1 += dy; + r.z0 += dz; r.z1 += dz; + this._updateTerrainRegionVisual(); + return n; + } + + /** Двигать preview-меш под курсор. Вызывается из mousemove. */ + _updateTerrainBrushPosition() { + if (this._activeTool !== 'terrain') return; + if (!this._terrainBrushPreview) return; + // === Smooth-режим: raycast по smooth-mesh, preview на surface === + if (this._terrainBrush?.terrainMode === 'smooth' && this._robloxTerrain?.grid) { + const ray = this.scene.createPickingRay( + this.scene.pointerX, this.scene.pointerY, + null, this.camera, + ); + const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain); + const hit = this.scene.pickWithRay(ray, pickPred); + if (hit && hit.hit && hit.pickedPoint) { + this._terrainBrushPreview.position.copyFrom(hit.pickedPoint); + } + return; + } + // Voxel-режим (как было) + const cell = this._pickTerrainCell(false); + if (!cell) return; + const S = TERRAIN_VOXEL_SIZE; + this._terrainBrushPreview.position.set( + (cell.x + 0.5) * S, + (cell.y + 0.5) * S, + (cell.z + 0.5) * S, + ); + } + + /** Публичный сеттер: выбрать тип блока для постановки. */ + setActiveBlockType(blockTypeId) { + this._activeBlockType = blockTypeId; + } + + /** Публичный сеттер: выбрать тип модели для постановки. */ + setActiveModelType(modelTypeId) { + this._activeModelType = modelTypeId; + } + + /** Тип примитива для постановки (cube/sphere/...). */ + setActivePrimitiveType(typeId) { + this._activePrimitiveType = typeId; + } + + getPrimitiveCount() { + return this.primitiveManager ? this.primitiveManager.getInstanceCount() : 0; + } + + /** Количество блоков (для status bar). */ + getBlockCount() { + return this.blockManager ? this.blockManager.count() : 0; + } + + /** Количество моделей-инстансов. */ + getModelCount() { + return this.modelManager ? this.modelManager.getInstanceCount() : 0; + } + + /** Подписаться на изменение выделения (UI / Inspector / Hierarchy). */ + setOnSelectionChange(cb) { + if (this.selection) { + // Объединяем со внутренней подпиской на gizmo + this.selection.setOnSelectionChange((sel) => { + this._updateGizmoForSelection(sel); + if (cb) cb(sel); + }); + } + } + + /** Текущее выделение (или null). */ + getSelection() { + return this.selection?.getSelection() || null; + } + + /** Выделить блок программно (например по клику в Hierarchy). */ + selectBlockAt(x, y, z) { + this.selection?.selectBlockAt(x, y, z); + } + + /** Выделить модель программно. */ + selectModelByInstanceId(id) { + this.selection?.selectModelByInstanceId(id); + } + + /** Снять выделение. */ + clearSelection() { + this.selection?.clear(); + } + + /** Удалить выделенный объект. */ + deleteSelected() { + this.selection?.deleteSelected(); + } + + /** + * Дублировать выделенный объект (Ctrl+D). + * Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y). + * Модель: создаёт копию со смещением +1 по X. + */ + duplicateSelected() { + const sel = this.selection?.getSelection(); + if (!sel) return; + if (sel.type === 'block') { + // Ищем свободную клетку рядом + const candidates = [ + [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1], [0, 1, 0], + ]; + for (const [dx, dy, dz] of candidates) { + const nx = sel.gridX + dx, ny = sel.gridY + dy, nz = sel.gridZ + dz; + if (ny < 0) continue; + if (!this.blockManager.hasBlock(nx, ny, nz)) { + this.blockManager.addBlock(nx, ny, nz, sel.blockTypeId); + this.selection.selectBlockAt(nx, ny, nz); + return; + } + } + } else if (sel.type === 'model') { + // Сохраняем все нужные поля выделения до того как промис завершится + // (selection может перезатереться к моменту resolve) + const typeId = sel.modelTypeId; + const sx = sel.x, sy = sel.y, sz = sel.z; + const rotY = sel.rotationY || 0; + this.modelManager.addInstance(typeId, sx + 1, sy, sz, rotY) + .then(newId => { + if (newId != null) this.selection?.selectModelByInstanceId(newId); + }) + .catch(err => { + // eslint-disable-next-line no-console + console.error('[BabylonScene] duplicate model error:', err); + }); + } else if (sel.type === 'userModel') { + const typeId = sel.userModelTypeId; + const sx = sel.x, sy = sel.y, sz = sel.z; + const rotY = sel.rotationY || 0; + this.userModelManager.addInstance(typeId, sx + 1, sy, sz, rotY, { + currentUserId: this._currentUserId || null, + }).then(newId => { + if (newId != null) this.selection?.selectUserModelByInstanceId(newId); + }).catch(err => { + console.error('[BabylonScene] duplicate user model error:', err); + }); + } else if (sel.type === 'primitive') { + const newId = this.primitiveManager.addInstance(sel.primitiveType, { + x: sel.x + 1, y: sel.y, z: sel.z, + sx: sel.sx, sy: sel.sy, sz: sel.sz, + color: sel.color, material: sel.material, + canCollide: sel.canCollide, visible: sel.visible, + anchored: sel.anchored, + // Копируем и спец-свойства: текстуру, параметры лампы/эмиттера. + textureAsset: sel.textureAsset || null, + brightness: sel.brightness, range: sel.range, effect: sel.effect, + }); + if (newId != null) this.selection.selectPrimitiveById(newId); + } + } + + /** + * Скопировать выделенный объект в буфер обмена (Ctrl+C, Фаза 5.10). + * Буфер — localStorage, поэтому переживает перезагрузку страницы + * и смену проекта (Copy/Paste между проектами). + */ + copySelected() { + const sel = this.selection?.getSelection(); + if (!sel) return; + let clip = null; + if (sel.type === 'block') { + clip = { kind: 'block', blockTypeId: sel.blockTypeId }; + } else if (sel.type === 'model') { + clip = { + kind: 'model', modelTypeId: sel.modelTypeId, + rotationY: sel.rotationY || 0, scale: sel.scale || 1, + }; + } else if (sel.type === 'userModel') { + clip = { + kind: 'userModel', userModelTypeId: sel.userModelTypeId, + rotationY: sel.rotationY || 0, + }; + } else if (sel.type === 'primitive') { + clip = { + kind: 'primitive', primitiveType: sel.primitiveType, + sx: sel.sx, sy: sel.sy, sz: sel.sz, + color: sel.color, material: sel.material, + canCollide: sel.canCollide, visible: sel.visible, + anchored: sel.anchored, + textureAsset: sel.textureAsset || null, + brightness: sel.brightness, range: sel.range, effect: sel.effect, + }; + } + if (clip) { + try { localStorage.setItem('kubikon_clipboard', JSON.stringify(clip)); } + catch (e) { /* ignore — приватный режим / переполнение */ } + } + } + + /** + * Вставить объект из буфера обмена (Ctrl+V, Фаза 5.10). + * Объект появляется у точки, куда смотрит редактор-камера. + */ + pasteFromClipboard() { + let clip; + try { + const raw = localStorage.getItem('kubikon_clipboard'); + if (!raw) return; + clip = JSON.parse(raw); + } catch (e) { return; } + if (!clip || !clip.kind) return; + // Точка вставки — перед редактор-камерой (~6м по направлению взгляда). + const cam = this.camera; + let px = 0, py = 1, pz = 0; + if (cam) { + const fwd = cam.getForwardRay ? cam.getForwardRay().direction : null; + if (fwd) { + px = cam.position.x + fwd.x * 6; + pz = cam.position.z + fwd.z * 6; + } + } + if (clip.kind === 'block') { + const gx = Math.round(px), gz = Math.round(pz); + let gy = 0; + while (gy < 64 && this.blockManager?.hasBlock(gx, gy, gz)) gy++; + this.blockManager?.addBlock(gx, gy, gz, clip.blockTypeId); + this.selection?.selectBlockAt(gx, gy, gz); + } else if (clip.kind === 'model') { + this.modelManager?.addInstance(clip.modelTypeId, px, py, pz, clip.rotationY || 0) + .then(id => { if (id != null) this.selection?.selectModelByInstanceId(id); }) + .catch(() => {}); + } else if (clip.kind === 'userModel') { + this.userModelManager?.addInstance( + clip.userModelTypeId, px, py, pz, clip.rotationY || 0, + { currentUserId: this._currentUserId || null }, + ).then(id => { if (id != null) this.selection?.selectUserModelByInstanceId(id); }) + .catch(() => {}); + } else if (clip.kind === 'primitive') { + const id = this.primitiveManager?.addInstance(clip.primitiveType, { + x: px, y: Math.max(py, (clip.sy || 1) / 2), z: pz, + sx: clip.sx, sy: clip.sy, sz: clip.sz, + color: clip.color, material: clip.material, + canCollide: clip.canCollide, visible: clip.visible, + anchored: clip.anchored, + textureAsset: clip.textureAsset || null, + brightness: clip.brightness, range: clip.range, effect: clip.effect, + }); + if (id != null) this.selection?.selectPrimitiveById(id); + } + if (this._onSceneChange) this._onSceneChange(); + } + + /** + * Поставить выделенный объект на пол (y = 0). + * Для блоков — gridY=0. Для моделей — нижняя граница на полу. Для примитивов — sy/2. + */ + alignSelectedToFloor() { + const sel = this.selection?.getSelection(); + if (!sel) return; + if (sel.type === 'block') { + this.selection.moveSelectedBlock(sel.gridX, 0, sel.gridZ); + } else if (sel.type === 'model') { + // Модель: rootMesh.position.y = низ модели. y=0 = низ на полу. + this.selection.moveSelectedModel(sel.x, 0, sel.z); + } else if (sel.type === 'userModel') { + this.selection.moveSelectedUserModel(sel.x, 0, sel.z); + } else if (sel.type === 'primitive') { + // Центр примитива должен быть на высоте sy/2 чтобы низ касался пола. + const halfH = (sel.sy || 1) / 2; + this.selection.moveSelectedPrimitive(sel.x, halfH, sel.z); + } + } + + /** + * View-preset — поставить редактор-камеру в одну из стандартных позиций. + * preset: 'top' | 'front' | 'side' | 'iso' + */ + setViewPreset(preset) { + if (!this.camera) return; + const presets = { + top: { pos: [0, 40, 0.01], rot: [Math.PI / 2, 0, 0] }, // прямо сверху + front: { pos: [0, 8, -25], rot: [0, 0, 0] }, // спереди + side: { pos: [25, 8, 0], rot: [0, -Math.PI / 2, 0] }, // сбоку + iso: { pos: [15, 15, -20], rot: [Math.PI / 5, -Math.PI / 5, 0] }, // изометрия + }; + const p = presets[preset]; + if (!p) return; + this.camera.position = new Vector3(p.pos[0], p.pos[1], p.pos[2]); + this.camera.rotation = new Vector3(p.rot[0], p.rot[1], p.rot[2]); + } + + /** + * Поставить точку спавна там где сейчас смотрит редактор-камера + * (полезно для размещения «тут начинать игру»). + * spawnPoint = (camera.x, max(0, floor(camera.y) - 1), camera.z). + */ + setSpawnAtCamera() { + if (!this.camera) return; + const p = this.camera.position; + this._spawnPoint = { + x: Math.round(p.x), + y: Math.max(0, Math.floor(p.y) - 1), + z: Math.round(p.z), + }; + this._updateSpawnMarker(); + this.history?.markChange(); + if (this._onSceneChange) this._onSceneChange(); + } + + /** Изменить позицию выделенного (используется Inspector). */ + 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; + // Сброс состояния касаний — каждый прогон начинается «не касаясь». + if (this._touchState) this._touchState.clear(); + this._playerMenuOpen = false; // меню-оверлей закрыт на старте Play + // По умолчанию стандартный HUD видим в Play. + // Скрипт может скрыть через game.hud.setVisible(false). + this._setStdHudVisible(true); + + // Включаем picking voxel-террейна — иначе камера _clampCameraToWorld + // не «видит» воксели в Ray-каст и пролетает сквозь стены. + try { this.terrainManager?.enablePickingForCamera?.(true); } catch (e) {} + + // Снимок редактор-камеры + this._editorCameraSnapshot = { + position: this.camera.position.clone(), + rotation: this.camera.rotation.clone(), + }; + + if (this._ghostMesh) this._ghostMesh.setEnabled(false); + this._setSpawnMarkerVisible(false); + // Триггеры — невидимые в Play, видимые в редакторе + this.primitiveManager?.setTriggersVisible(false); + + // Запоминаем исходные позиции unanchored-объектов чтобы вернуть + // их при выходе из Play (физика двигает mesh.position). + this._snapshotDynamicObjects(); + // Полный снимок примитивов и моделей — чтобы при Stop откатить + // ВСЕ изменения скриптов (удаления, цвет, видимость, повороты). + this._snapshotFullScene(); + + // Запускаем физику unanchored + this.dynamics?.start(); + + // Запускаем фоновую музыку и амбиент + this.audioManager?.start(); + + // Создаём PlayerController и стартуем + this.player = new PlayerController(this.scene, this.canvas, this.physics, this); + this.player.setModelType(this._playerModelType); + // Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck + try { + this.modalManager?.attachPlayer?.(this.player); + this.modalManager?.attachAudio?.(this.audioManager); + } catch (e) {} + this.player._jumpPowerMul = this._jumpPowerMul ?? 1; + // Применяем дефолтную камеру если задана в сцене + if (this._defaultCameraMode && this._defaultCameraMode !== this.player._cameraMode) { + this.player._cameraMode = this._defaultCameraMode; + } + // На тач-устройствах отключаем pointer-lock и mouse-камеру + if (this._touchMode) this.player.setTouchMode(true); + this.player.setOnExitRequest(() => { + // Задача 07: магазин скинов открыт → Esc закрывает его (приоритет выше модала). + if (this._skinShop?.open) { + this._closeSkinShop(); + return; + } + // Задача 04: если открыт модал — первый Esc закрывает его, + // второй Esc уже выходит из Play. Так юзер не теряет состояние игры + // случайно при попытке скрыть модал. + if (this.modalManager?.isOpen?.()) { + this.modalManager.close(); + return; + } + // ESC в плеере = TOGGLE меню-оверлея поверх ЖИВОЙ игры (как в Roblox). + // Единый источник истины — _playerMenuOpen в движке. Раньше состояние + // меню держал React, а ESC слушали ДВА обработчика (движок + React) → + // гонка: меню открывалось поверх меню, а _uiCursorMode застревал в true + // → orbit-камера по ПКМ переставала работать после закрытия меню. + // Теперь движок сам решает open/close и шлёт это в _onEscMenu(open). + if (typeof this._onEscMenu === 'function') { + if (this._playerMenuOpen) { + // Меню открыто → ESC закрывает: вернуть мышь в игру. + this._playerMenuOpen = false; + this.player?.setUiCursorMode?.(false); + this._onEscMenu(false); + } else { + // Меню закрыто → ESC открывает: освободить курсор. + this._playerMenuOpen = true; + this.player?.setUiCursorMode?.(true); + this._onEscMenu(true); + } + return; + } + // Фолбэк (если меню не подписано, напр. в студии) — старое поведение. + this.exitPlayMode(); + if (this._onPlayChange) this._onPlayChange(false); + }); + if (this._onPlayerHpChange) this.player.setOnHpChange(this._onPlayerHpChange); + if (this._onPlayerDeath) this.player.setOnDeath(this._onPlayerDeath); + this.player.start(this._spawnPoint); + + // Запускаем пользовательские скрипты (этап 2.1). + // Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену, + // поэтому скрипты стартуем в следующем кадре. + this.gameRuntime = new GameRuntime(this); + try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {} + // Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime. + // ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным + // this.audioManager (AudioManager — ambient/music для всех проектов). + if (!this.gameAudioManager) { + this.gameAudioManager = new GameAudioManager(); + } + // GD-уровень (Этап 5.1): автоматически обрабатывает GD-порталы, шипы, финиш, монеты. + // Юзер просто ставит объекты из палитры (категории "GD-порталы" и "GD-объекты") в редакторе. + if (!this.gdLevelManager) { + this.gdLevelManager = new GdLevelManager(this); + this.gdLevelManager.setOnPortalEnter((newMode, prevMode) => { + try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdPortalEnter', data: { newMode, prevMode } }); } catch (e) {} + try { this.gameAudioManager?.playSfx?.('flip'); } catch (e) {} + }); + this.gdLevelManager.setOnDeath((info) => { + try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdDeath', data: info }); } catch (e) {} + try { this.gameAudioManager?.playSfx?.('death'); } catch (e) {} + // Респавн игрока через teleport на spawnPoint + try { + const sp = this._spawnPoint || { x: 0, y: 2, z: 0 }; + this.player.teleport(sp.x, sp.y, sp.z); + // Сбросить vy чтобы не нести инерцию из шипа + if (this.player) this.player._vy = 0; + } catch (e) {} + }); + this.gdLevelManager.setOnFinish((info) => { + const stats = this.gdLevelManager.getCoinsStats(); + try { + this.gameRuntime?.routeGlobalEvent?.('message', { + name: 'gdFinish', + data: { ...info, coinsCollected: stats.collected, coinsTotal: stats.total }, + }); + } catch (e) {} + try { this.gameAudioManager?.playSfx?.('level_complete'); } catch (e) {} + }); + this.gdLevelManager.setOnCoinCollected((info) => { + try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdCoinCollected', data: info }); } catch (e) {} + try { this.gameAudioManager?.playSfx?.('coin'); } catch (e) {} + }); + } + this.gdLevelManager.start(); + // Этапы G1/G2: skybox+параллакс+декоративная трава для GD-уровней. + // Откладываем на setTimeout — primitiveManager.instances наполняется + // не сразу при enterPlayMode (load.primitives асинхронный). + // GD-проект определяется флагом settings.isGd (см. serialize/loadFromState). + // Fallback для старых проектов БЕЗ флага — реальные id GD-уровней: + // - 295: GD 2.0 sandbox + // - 296..306: L1-L11 (эпоха 1 + L11 легаси) + // - 350..358: L12-L20 (эпоха 2) + // Раньше было только 296..315 — L12-L20 (id 350..358) НЕ попадали, + // и веб-плеер не активировал GD-инфру (шипы конусы вместо .glb, + // нет skybox/forest на заднем фоне). На APK работало правильно. + const _pid = Number(this._currentProjectId); + const isGd = (typeof this._isGdProject === 'boolean') + ? this._isGdProject + : ((_pid >= 296 && _pid <= 315) + || (_pid >= 350 && _pid <= 358) + || _pid === 295); + console.log(`[GD-gfx] currentProjectId=${this._currentProjectId}, isGd=${isGd}, flag=${this._isGdProject}`); + if (isGd) { + // Ширина уровня — по самому правому cube-блоку + let levelWidth = 1000; + if (this.blockManager && this.blockManager.blocks) { + for (const key of this.blockManager.blocks.keys()) { + const x = parseInt(String(key).split(',')[0], 10); + if (Number.isFinite(x) && x > levelWidth) levelWidth = x; + } + } + setTimeout(() => { + try { + if (!this.gdSkybox) { + this.gdSkybox = new GdSkybox(); + const cam = this.player?.camera || this.scene.activeCamera; + this.gdSkybox.attach(this.scene, cam); + console.log('[GD-gfx] skybox attached'); + } + if (!this.gdGroundSkin) { + this.gdGroundSkin = new GdGroundSkin(); + this.gdGroundSkin.attach(this.scene, levelWidth, this._shadowGenerator, this); + console.log('[GD-gfx] groundSkin attached, width=', levelWidth); + } + // Эпоха по project_id. L11 = 306 (легаси). L12-L20 = 350-358. + const pid = Number(this._currentProjectId) || 296; + const GD_PID_TO_EPOCH = { + 296:1, 297:1, 298:1, 299:1, 300:1, 301:1, 302:1, 303:1, 304:1, 305:1, + 306:2, 350:2, 351:2, 352:2, 353:2, 354:2, 355:2, 356:2, 357:2, 358:2, + }; + const epoch = GD_PID_TO_EPOCH[pid] || 1; + if (!this.gdSpikes) { + this.gdSpikes = new GdSpikes(); + this.gdSpikes.attach(this.scene, this, epoch); + } + if (!this.gdStartArch) { + this.gdStartArch = new GdStartArch(); + this.gdStartArch.attach(this.scene, epoch); + } + if (!this.gdPortalArch) { + this.gdPortalArch = new GdPortalArch(); + this.gdPortalArch.attach(this.scene, this, this._currentUserId); + } + if (!this.gdDiamond) { + this.gdDiamond = new GdDiamond(); + this.gdDiamond.attach(this.scene, this); + } + if (!this.gdFinish) { + this.gdFinish = new GdFinish(); + this.gdFinish.attach(this.scene, this, epoch); + } + if (!this.gdForest) { + this.gdForest = new GdForest(); + this.gdForest.attach(this.scene, levelWidth, epoch); + } + if (!this.gdPlayerCube) { + this.gdPlayerCube = new GdPlayerCube(); + this.gdPlayerCube.attach(this.scene, this); + } + if (!this.gdPlayerModeSkin) { + // Задержка 600мс — даём скрипту уровня применить базовый cube-skin, + // чтобы _origTexture при первой смене режима содержала правильную текстуру. + setTimeout(() => { + this.gdPlayerModeSkin = new GdPlayerModeSkin(); + this.gdPlayerModeSkin.attach(this.scene, this, this._currentUserId); + }, 600); + } + if (!this.gdPlayerTrail) { + this.gdPlayerTrail = new GdPlayerTrail(); + this.gdPlayerTrail.attach(this.scene, this, this._currentProjectId, this._currentUserId); + } + if (!this.gdPostFx) { + this.gdPostFx = new GdPostFx(); + const cam = this.player?.camera || this.scene.activeCamera; + this.gdPostFx.attach(this.scene, cam, this); + } + // Тени отключены — делаем через GdGroundSkin (fake shadows) + this._enableGdShadows(); + } catch (e) { console.warn('[BabylonScene] GD-graphics attach failed', e); } + }, 50); + } + if (this._onScriptLog) this.gameRuntime.setOnLog(this._onScriptLog); + if (this._onScriptHud) this.gameRuntime.setOnHud(this._onScriptHud); + if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair); + // eslint-disable-next-line no-console + console.log('[BabylonScene] enterPlayMode — scripts to run:', this._scripts?.length || 0, this._scripts); + // Старт через requestAnimationFrame — даём Babylon собрать сцену + requestAnimationFrame(() => { + if (this._isPlaying) this.gameRuntime?.start(this._scripts || []); + }); + + // === Оружие === + if (!this.weapons) this.weapons = new WeaponSystem(this); + if (this._onAmmoChange) this.weapons.setOnAmmoChange(this._onAmmoChange); + // Подключаем зомби-логику к попаданиям пули + this.weapons.setOnHit((hit) => { + if (hit?.mesh && this.zombieManager) { + this.zombieManager.damageByMesh(hit.mesh, hit.damage || 25); + } + if (this._onWeaponHit) { + try { this._onWeaponHit(hit); } catch (e) {} + } + }); + this.weapons.start(); + + // === Зомби-система === + if (!this.zombieManager) this.zombieManager = new ZombieManager(this); + if (!this.spawnerManager) this.spawnerManager = new ZombieSpawnerManager(this, this.zombieManager); + this.zombieManager.start(); + this.spawnerManager.start(); + + // === NPC-система (Фаза 4.1) — управляемые скриптом персонажи === + if (!this.npcManager) this.npcManager = new NpcManager(this); + this.npcManager.start(); + + // === Связи объектов (Фаза 5, Constraints) === + if (!this.constraintManager) this.constraintManager = new ConstraintManager(this); + this.constraintManager.start(); + + // === Лучи и следы (Фаза 5.2 — Beam/Trail) === + if (!this.beamManager) this.beamManager = new BeamManager(this); + this.beamManager.start(); + // Задача 08: активируем pointer-примитивы из палитры в реальные стрелки. + this._activatePointers(); + + // === 3D-звук (Фаза 5.5 — позиционный звук) === + if (!this.soundManager) this.soundManager = new SoundManager(this); + this.soundManager.start(); + // Регистрируем gameplay-объекты: зомби и спавнеры. + // Применяем defaults + текущие gameplayParams из инспектора. + if (this.modelManager) { + for (const data of this.modelManager.instances.values()) { + const gp = data.gameplay; + if (!gp) continue; + const params = { ...(gp.defaultParams || {}), ...(data.gameplayParams || {}) }; + if (gp.isZombie) { + this.zombieManager.registerExisting(data.instanceId, params); + } else if (gp.isZombieSpawner) { + this.spawnerManager.register(data.instanceId, params); + } + } + } + // Снаряжаем оружие из активного слота инвентаря + const active = this.inventory?.getActive?.(); + if (active) this.weapons.equip(active); + + // Замораживаем world-matrix у всех статичных GLB-моделей + // (не зомби и не спавнеры). Деревья, дома, камни не двигаются — + // Babylon не должен пересчитывать их матрицы каждый кадр. + try { this.modelManager?.freezeStaticModels?.(); } catch (e) {} + try { this.primitiveManager?.freezeStaticPrimitives?.(); } catch (e) {} + + // ОПТИМИЗАЦИЯ ОТКЛЮЧЕНА: octree селекшн. + // Octree создаётся один раз и не «знает» о мешах добавленных позже — + // даже с alwaysSelectAsActiveMesh новые меши (трейсеры выстрелов, + // debris-кубы при смерти, динамические объекты) фактически выпадают + // из активного списка → невидимы. Стандартный frustum-culling Babylon + // дешёвый сам по себе для нашей сцены, octree больше вреда чем пользы. + } + + /** Заглушка для совместимости — раньше пересоздавала octree. */ + setActiveMeshesDirty() { + // no-op + } + + /** Установить колбэк логов от скриптов (для Console-панели UI). */ + setOnScriptLog(cb) { + this._onScriptLog = cb; + if (this.gameRuntime) this.gameRuntime.setOnLog(cb); + } + + /** Колбэк команд HUD от скриптов (для GameHud React-компонента). */ + setOnScriptHud(cb) { + this._onScriptHud = cb; + if (this.gameRuntime) this.gameRuntime.setOnHud(cb); + } + + /** Колбэк смены прицела из скрипта (game.player.crosshair = 'cross'). */ + setOnScriptCrosshair(cb) { + this._onScriptCrosshair = cb; + if (this.gameRuntime) this.gameRuntime.setOnCrosshairChange(cb); + } + + // ============================================================ + // Таймер прохождения (для лидерборда) + // ============================================================ + /** cb({state: 'start'|'stop'|'submit', timeMs}) */ + setOnTimer(cb) { this._onTimer = cb; } + + _timerStart() { + this._timerStartedAt = performance.now(); + this._timerRunning = true; + if (this._onTimer) try { this._onTimer({ state: 'start', timeMs: 0 }); } catch (e) {} + } + _timerStop() { + if (!this._timerRunning) return; + const ms = Math.round(performance.now() - (this._timerStartedAt || 0)); + this._timerRunning = false; + if (this._onTimer) try { this._onTimer({ state: 'stop', timeMs: ms }); } catch (e) {} + } + _timerSubmit() { + if (!this._timerRunning && !this._timerStartedAt) return; + const ms = Math.round(performance.now() - (this._timerStartedAt || 0)); + this._timerRunning = false; + if (this._onTimer) try { this._onTimer({ state: 'submit', timeMs: ms }); } catch (e) {} + } + /** Получить текущее время таймера в мс (или 0 если не запущен). */ + getTimerMs() { + if (!this._timerRunning || !this._timerStartedAt) return 0; + return Math.round(performance.now() - this._timerStartedAt); + } + isTimerRunning() { return !!this._timerRunning; } + + /** PERF-METRICS: получить и сбросить накопленные метрики за окно. */ + flushPerfMetrics() { + const m = this._perfMetrics; + if (!m) return null; + const out = { + render_ms_avg: m.render_count ? (m.render_ms_sum / m.render_count) : 0, + physics_ms_avg: m.physics_count ? (m.physics_ms_sum / m.physics_count) : 0, + script_ms_avg: m.script_count ? (m.script_ms_sum / m.script_count) : 0, + idle_ms_avg: m.idle_count ? (m.idle_ms_sum / m.idle_count) : 0, + render_count: m.render_count, + physics_count: m.physics_count, + script_count: m.script_count, + }; + m.render_ms_sum = 0; m.render_count = 0; + m.physics_ms_sum = 0; m.physics_count = 0; + m.script_ms_sum = 0; m.script_count = 0; + m.idle_ms_sum = 0; m.idle_count = 0; + return out; + } + + /** + * Поставить render-loop на паузу. + * Используется когда Babylon canvas не виден (активен таб скрипта), + * чтобы освободить CPU/GPU и Monaco не лагал. + * НЕ останавливает Play-режим — только рендер. + */ + pauseRendering() { this._renderingPaused = true; } + resumeRendering() { this._renderingPaused = false; } + isRenderingPaused() { return !!this._renderingPaused; } + + /** + * Создать эффект частиц в точке. Вызывается из GameRuntime._spawnParticles + * (через game.scene.spawnParticles в скриптах). + * + * payload: { type, position: {x,y,z}, duration, count, color } + */ + _spawnParticleEffect(payload) { + if (!payload || !this.scene) return; + const pos = payload.position || { x: 0, y: 0, z: 0 }; + const type = payload.type || 'sparks'; + const duration = Math.max(0.1, Math.min(20, Number(payload.duration) || 1.5)); + const countMul = Math.max(0.1, Math.min(10, Number(payload.count) || 1)); + + // Кэшируем текстуру частицы — один раз на сцену + if (!this._particleTex) { + const tex = new DynamicTexture('particleTex', { width: 64, height: 64 }, this.scene, false); + const ctx = tex.getContext(); + const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); + grad.addColorStop(0, 'rgba(255,255,255,1)'); + grad.addColorStop(0.4, 'rgba(255,255,255,0.6)'); + grad.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, 64, 64); + tex.update(); + tex.hasAlpha = true; + this._particleTex = tex; + } + + // MOBILE-OPT (этап 1): на мобильном уменьшаем кол-во частиц в 2 раза + const baseCount = this._isMobileMode ? 40 : 80; + const ps = new ParticleSystem('p_' + Date.now(), + Math.floor(baseCount * countMul), this.scene); + ps.particleTexture = this._particleTex; + ps.emitter = new Vector3(pos.x, pos.y, pos.z); + ps.minEmitBox = new Vector3(-0.1, -0.1, -0.1); + ps.maxEmitBox = new Vector3(0.1, 0.1, 0.1); + ps.blendMode = ParticleSystem.BLENDMODE_ADD; + + const customColor = payload.color && /^#[0-9a-fA-F]{6}$/.test(payload.color) + ? payload.color : null; + this._configureParticleSystem(ps, type, customColor, countMul); + + ps.start(); + // Авто-стоп: для explosion почти сразу (это burst), для остальных = duration + const stopAt = type === 'explosion' ? 0.05 : duration; + const disposeAt = stopAt + (ps.maxLifeTime || 1) + 0.3; + setTimeout(() => { try { ps.stop(); } catch (e) {} }, stopAt * 1000); + // dispose(false) — particleTexture расшарена (_particleTex), не удалять. + setTimeout(() => { try { ps.dispose(false); } catch (e) {} }, disposeAt * 1000); + } + + /** + * Настроить параметры ParticleSystem под тип эффекта. + * Общий конфигуратор для разового эффекта (_spawnParticleEffect) и + * постоянного эмиттера-объекта (createEmitterParticles). + */ + _configureParticleSystem(ps, type, customColor, countMul = 1) { + const hexToColor4 = (hex, a = 1) => { + const r = parseInt(hex.substr(1, 2), 16) / 255; + const g = parseInt(hex.substr(3, 2), 16) / 255; + const b = parseInt(hex.substr(5, 2), 16) / 255; + return new Color4(r, g, b, a); + }; + + switch (type) { + case 'fire': + ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.6, 0.1, 1); + ps.color2 = customColor ? hexToColor4(customColor, 0.8) : new Color4(1, 0.2, 0, 1); + ps.colorDead = new Color4(0.2, 0, 0, 0); + ps.minSize = 0.2; ps.maxSize = 0.5; + ps.minLifeTime = 0.4; ps.maxLifeTime = 1.0; + ps.emitRate = 80; + ps.gravity = new Vector3(0, 1.2, 0); + ps.direction1 = new Vector3(-0.4, 1.5, -0.4); + ps.direction2 = new Vector3(0.4, 2.0, 0.4); + ps.minEmitPower = 0.5; ps.maxEmitPower = 1.2; + break; + case 'smoke': + ps.color1 = new Color4(0.4, 0.4, 0.4, 0.6); + ps.color2 = new Color4(0.2, 0.2, 0.2, 0.4); + ps.colorDead = new Color4(0, 0, 0, 0); + ps.minSize = 0.4; ps.maxSize = 1.2; + ps.minLifeTime = 1.5; ps.maxLifeTime = 3; + ps.emitRate = 40; + ps.gravity = new Vector3(0, 0.5, 0); + ps.direction1 = new Vector3(-0.3, 1, -0.3); + ps.direction2 = new Vector3(0.3, 1.5, 0.3); + ps.minEmitPower = 0.3; ps.maxEmitPower = 0.7; + ps.blendMode = ParticleSystem.BLENDMODE_STANDARD; + break; + case 'sparks': + ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 1, 0.4, 1); + ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.6, 0, 1); + ps.colorDead = new Color4(0.5, 0.2, 0, 0); + ps.minSize = 0.05; ps.maxSize = 0.15; + ps.minLifeTime = 0.3; ps.maxLifeTime = 0.8; + ps.emitRate = 200; + ps.gravity = new Vector3(0, -8, 0); + ps.direction1 = new Vector3(-3, 4, -3); + ps.direction2 = new Vector3(3, 7, 3); + ps.minEmitPower = 1; ps.maxEmitPower = 3; + break; + case 'magic': + ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(0.6, 0.3, 1, 1); + ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(0.3, 0.6, 1, 1); + ps.colorDead = new Color4(0.2, 0, 0.5, 0); + ps.minSize = 0.15; ps.maxSize = 0.35; + ps.minLifeTime = 1; ps.maxLifeTime = 2.2; + ps.emitRate = 60; + ps.gravity = new Vector3(0, 0.3, 0); + ps.direction1 = new Vector3(-1, 1, -1); + ps.direction2 = new Vector3(1, 2, 1); + ps.minEmitPower = 0.5; ps.maxEmitPower = 1.5; + break; + case 'explosion': + ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.7, 0.2, 1); + ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.3, 0, 1); + ps.colorDead = new Color4(0.2, 0, 0, 0); + ps.minSize = 0.3; ps.maxSize = 0.8; + ps.minLifeTime = 0.4; ps.maxLifeTime = 1.0; + ps.emitRate = 0; + ps.manualEmitCount = Math.floor(120 * countMul); + ps.gravity = new Vector3(0, -3, 0); + ps.direction1 = new Vector3(-5, -1, -5); + ps.direction2 = new Vector3(5, 5, 5); + ps.minEmitPower = 2; ps.maxEmitPower = 6; + break; + case 'confetti': + ps.color1 = new Color4(1, 0.3, 0.3, 1); + ps.color2 = new Color4(0.3, 0.6, 1, 1); + ps.colorDead = new Color4(0, 0, 0, 0); + ps.minSize = 0.1; ps.maxSize = 0.25; + ps.minLifeTime = 1.5; ps.maxLifeTime = 3; + ps.emitRate = 100; + ps.gravity = new Vector3(0, -3, 0); + ps.direction1 = new Vector3(-3, 5, -3); + ps.direction2 = new Vector3(3, 8, 3); + ps.minEmitPower = 1; ps.maxEmitPower = 3; + break; + default: + ps.color1 = new Color4(1, 1, 1, 1); + ps.color2 = new Color4(0.7, 0.7, 0.7, 0.5); + ps.colorDead = new Color4(0, 0, 0, 0); + ps.minSize = 0.1; ps.maxSize = 0.3; + ps.minLifeTime = 0.5; ps.maxLifeTime = 1.5; + ps.emitRate = 60; + ps.direction1 = new Vector3(-1, 1, -1); + ps.direction2 = new Vector3(1, 2, 1); + } + } + + /** + * Создать ПОСТОЯННУЮ систему частиц для эмиттера-объекта (костёр и т.п.). + * Не имеет авто-стопа — горит пока объект существует. Возвращает ps. + */ + createEmitterParticles(type, position, color) { + if (!this.scene) return null; + if (!this._particleTex) { + const tex = new DynamicTexture('particleTex', { width: 64, height: 64 }, this.scene, false); + const ctx = tex.getContext(); + const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); + grad.addColorStop(0, 'rgba(255,255,255,1)'); + grad.addColorStop(0.4, 'rgba(255,255,255,0.6)'); + grad.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, 64, 64); + tex.update(); + tex.hasAlpha = true; + this._particleTex = tex; + } + const baseCount = this._isMobileMode ? 60 : 120; + const ps = new ParticleSystem('emitter_' + Date.now(), baseCount, this.scene); + ps.particleTexture = this._particleTex; + ps.emitter = new Vector3(position.x, position.y, position.z); + ps.minEmitBox = new Vector3(-0.15, -0.1, -0.15); + ps.maxEmitBox = new Vector3(0.15, 0.1, 0.15); + ps.blendMode = ParticleSystem.BLENDMODE_ADD; + const customColor = color && /^#[0-9a-fA-F]{6}$/.test(color) ? color : null; + // explosion как постоянный эффект не имеет смысла → fire + const effType = type === 'explosion' ? 'fire' : type; + this._configureParticleSystem(ps, effType, customColor, 1); + ps.start(); + return ps; + } + + /** + * Запустить ОДИН скрипт без Play-режима (отладочный запуск из редактора). + * Если runtime уже есть — переиспользуем, иначе создаём. + */ + startSoloScript(scriptId) { + const all = this._scripts || []; + const sc = all.find(s => s.id === scriptId); + if (!sc) return false; + if (!this.gameRuntime) { + this.gameRuntime = new GameRuntime(this); + try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {} + if (!this.gameAudioManager) { + this.gameAudioManager = new GameAudioManager(); + } + if (this._onScriptLog) this.gameRuntime.setOnLog(this._onScriptLog); + if (this._onScriptHud) this.gameRuntime.setOnHud(this._onScriptHud); + if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair); + } + this.gameRuntime.startSolo(sc); + return true; + } + + /** Остановить отладочный (solo) запуск. */ + stopSoloScript() { + if (this.gameRuntime && this.gameRuntime.isSolo?.()) { + this.gameRuntime.stop(); + // Если не в Play-режиме — освобождаем runtime + if (!this._isPlaying) { + this.gameRuntime = null; + } + } + } + + isSoloRunning() { + return !!this.gameRuntime?.isSolo?.(); + } + getSoloScriptId() { + return this.gameRuntime?.getSoloScriptId?.() || null; + } + + /** Получить все скрипты проекта. */ + getScripts() { return [...this._scripts]; } + + /** Заменить все скрипты (используется при load/edit). */ + setScripts(scripts) { + this._scripts = Array.isArray(scripts) ? scripts.slice() : []; + } + + /** Установить код одного скрипта по id. Если id нет — создать новый. */ + upsertScript(id, code, target = undefined) { + const i = this._scripts.findIndex(s => s.id === id); + if (i >= 0) { + this._scripts[i] = { + ...this._scripts[i], + code, + ...(target !== undefined ? { target } : {}), + }; + } else { + this._scripts.push({ + id: id || `script_${Date.now()}`, + code, + target: target !== undefined ? target : null, + }); + } + // Скрипты — часть сцены: фиксируем в истории, иначе undo откатит + // _scripts к снапшоту, снятому до создания скрипта, и скрипт пропадёт. + this.history?.markChange(); + if (this._onSceneChange) this._onSceneChange(); + } + + /** Удалить скрипт по id. */ + removeScript(id) { + this._scripts = this._scripts.filter(s => s.id !== id); + this.history?.markChange(); + if (this._onSceneChange) this._onSceneChange(); + } + + /** + * Зарегистрировать колбэк для уведомлений об изменении режима Play + * (вызывается когда player сам инициирует exit, например по Esc). + * KubikonEditor подписывается чтобы синхронизировать React-state. + */ + setOnPlayChange(cb) { + this._onPlayChange = cb; + } + + /** + * Колбэк «ESC в Play» для плеера: открыть меню-оверлей поверх живой игры + * БЕЗ выхода из Play. Если подписан — ESC не делает exitPlayMode (см. + * setOnExitRequest в enterPlayMode). В студии не подписывается → там ESC + * по-прежнему выходит из Play. + */ + setOnEscMenu(cb) { + this._onEscMenu = cb; + } + + /** + * Синхронизация состояния меню-оверлея из UI (React). Когда меню закрывают + * НЕ через ESC (кнопка «Продолжить»/крестик/клик по фону), UI обязан сообщить + * движку — иначе _playerMenuOpen рассинхронизируется и следующий ESC решит, + * что меню «открыто», и не откроет его. open=false также возвращает мышь в игру. + */ + setPlayerMenuOpen(open) { + const v = !!open; + if (this._playerMenuOpen === v) return; + this._playerMenuOpen = v; + if (!v) { + // меню закрыли из UI → вернуть управление камерой/мышью + try { this.player?.setUiCursorMode?.(false); } catch (e) { /* ignore */ } + } + } + + /** + * Колбэк изменения сцены (любая модификация блоков/моделей). + * Используется KubikonEditor для dirty-tracking → auto-save. + * Сами обработчики на blockManager/modelManager привязаны в init() — + * они дёргают и history.markChange() и this._onSceneChange. + */ + setOnSceneChange(cb) { + this._onSceneChange = cb; + } + + /** Колбэк изменения GUI-элементов (для перерисовки React-overlay). */ + setOnGuiChange(cb) { + this._onGuiChange = cb; + } + + /** Подключить API для пользовательских моделей (Kubikon3DService). + * Нужно дважды: setApi для самого UserModelManager (getUserModel) + * и сохранить _userModelsApi для incrementModelUses в _handlePlaceModel. */ + setUserModelsApi(api) { + this._userModelsApi = api; + if (this.userModelManager && api) { + this.userModelManager.setApi(api); + } + } + + /** Передать id текущего пользователя — для запросов к приватным моделям. */ + setCurrentUserId(userId) { + this._currentUserId = userId; + } + + /** Передать id текущего проекта — для game.save.* эндпоинтов (savegame API). + * Без этого скрипты не смогут сохранять прогресс. */ + setCurrentProjectId(projectId) { + this._currentProjectId = projectId; + } + + /** Колбэк изменения видимости стандартного HUD (HP-бар, hotbar, ...). + * Редактор/плеер подписываются и реактивно скрывают/показывают элементы. + * Скрипт зовёт game.hud.setVisible(false) → этот колбэк сработает. */ + setOnStdHudVisibilityChange(cb) { + this._onStdHudVisibilityChange = cb; + } + _setStdHudVisible(visible) { + this._stdHudVisible = !!visible; + try { this._onStdHudVisibilityChange?.(this._stdHudVisible); } catch (e) {} + } + + /** Скрыть/показать только хотбар (5 слотов инвентаря снизу). */ + _setHotbarVisible(visible) { + this._hotbarVisible = !!visible; + try { this._onHotbarVisibilityChange?.(this._hotbarVisible); } catch (e) {} + } + + /** Скрыть/показать только HP-индикатор (полоска жизней). */ + _setHpVisible(visible) { + this._hpVisible = !!visible; + try { this._onHpVisibilityChange?.(this._hpVisible); } catch (e) {} + } + + /** Колбэк смены cursor-режима (ui/game) скриптом через game.input.setCursorMode. + * Редактор подписан чтобы синхронизировать React-state uiCursorMode (для бейджа). */ + setOnCursorModeChange(cb) { + this._onCursorModeChange = cb; + } + + /** Пересобрать spatial-индекс физики для user-моделей. + * Вызывается из SelectionManager при изменении canCollide / anchored / + * position / rotation / scale. */ + _syncUserModelColliders() { + try { this.physics?.setSpatialDirty?.(); } catch (e) {} + } + + /** Инвалидировать пользовательскую модель после её редактирования. + * Сбрасывает кэш + пересоздаёт все инстансы этой модели в сцене с + * новой геометрией. Вызывается из KubikonEditor.jsx после закрытия + * редактора модели (когда editingUserModelId != null). */ + async refreshUserModel(userModelId) { + if (!this.userModelManager) return 0; + const rebuilt = await this.userModelManager.invalidateModel(userModelId, { + rebuild: true, + currentUserId: this._currentUserId || null, + }); + // Тени для свежесозданных мешей + if (rebuilt > 0) { + for (const inst of this.userModelManager.instances.values()) { + if (inst.userModelId === userModelId) { + for (const m of inst.meshes) { + try { this.addShadowCaster(m); } catch (e) {} + } + } + } + this._syncUserModelColliders(); + } + return rebuilt; + } + + /** Колбэк изменения инвентаря (для hot-bar React). */ + setOnInventoryChange(cb) { + this._onInventoryChange = cb; + } + + /** Колбэк изменения патронов оружия (для GUI). */ + setOnAmmoChange(cb) { + this._onAmmoChange = cb; + if (this.weapons) this.weapons.setOnAmmoChange(cb); + } + + /** Колбэк попадания пули (для логики урона зомби и др.). */ + setOnWeaponHit(cb) { + this._onWeaponHit = cb; + if (this.weapons) this.weapons.setOnHit(cb); + } + + /** Колбэк изменения HP игрока. */ + setOnPlayerHpChange(cb) { + this._onPlayerHpChange = cb; + if (this.player) this.player.setOnHpChange(cb); + } + + /** Колбэк смерти игрока. */ + setOnPlayerDeath(cb) { + this._onPlayerDeath = cb; + if (this.player) this.player.setOnDeath(cb); + } + + /** Колбэк Escape в редакторе (для возврата в инструмент «Выделить»). */ + setOnEditorEscape(cb) { + this._onEditorEscape = cb; + } + + getInventoryState() { + return this.inventory ? this.inventory.serialize() : { slots: [], activeIndex: 0 }; + } + + setActiveInventorySlot(index) { + this.inventory?.setActive(index); + // Если в Play — пересменяем оружие + if (this._isPlaying && this.weapons) { + const active = this.inventory?.getActive?.(); + if (active && active.kind === 'weapon') { + this.weapons.equip(active); + } else { + this.weapons.unequip(); + } + // Сообщаем мультиплееру о смене оружия — чтобы remote-клиенты + // увидели в руке нашей модели правильный GLB. + if (this._mpSync) { + const modelId = (active && active.kind === 'weapon') + ? (active.modelTypeId || '') + : ''; + try { this._mpSync.sendWeapon(modelId); } catch (e) {} + } + } + } + + addInventoryItem(item) { + return this.inventory?.add(item) ?? -1; + } + + /** API для UI: создать/изменить/удалить GUI-элемент. Делегирует в GuiManager. */ + createGuiElement(type, opts) { + return this.guiManager?.create(type, opts); + } + updateGuiElement(id, patch) { + this.guiManager?.update(id, patch); + } + removeGuiElement(id) { + // Если был выделен — снять выделение + if (this.selection?._selection?.type === 'gui' && this.selection._selection.id === id) { + this.selection.clearSelection?.(); + } + this.guiManager?.remove(id); + } + renameGuiElement(id, name) { + this.guiManager?.rename(id, name); + } + moveGuiElementZ(id, direction) { + this.guiManager?.moveZ(id, direction); + } + getGuiElements() { + return this.guiManager ? this.guiManager.getAll() : []; + } + + // ===== Задача 07: встроенный магазин скинов (React-оверлей) ===== + // Состояние держим тут; React-компонент SkinShopOverlay поллит getSkinShopState(). + _ensureSkinShopState() { + if (!this._skinShop) { + this._skinShop = { + open: false, + rev: 0, // ревизия — React видит изменение + data: { all: [], unlocked: [], current: null, coins: 0, manifestFull: [] }, + buyResult: null, // последний результат покупки {slug, ok, reason} + }; + } + return this._skinShop; + } + /** Снимок состояния магазина для React (поллинг через rAF). */ + getSkinShopState() { return this._skinShop || null; } + /** Открыть/закрыть магазин (из скрипта или клавиши B). */ + _openSkinShop() { + const s = this._ensureSkinShopState(); + // Отключён в проекте? (скрипт всё равно может открыть через API — + // shopVisible:false запрещает только клавишу B, см. toggleSkinShop). + s.open = true; s.rev++; + } + _closeSkinShop() { + const s = this._ensureSkinShopState(); + s.open = false; s.rev++; + } + toggleSkinShop() { + const s = this._ensureSkinShopState(); + if (s.open) { this._closeSkinShop(); return; } + // Клавиша B открывает магазин только если он включён в проекте. + if (this._skinsConfig && this._skinsConfig.shopVisible === false) return; + this._openSkinShop(); + } + /** Данные скинов от GameRuntime (манифест + unlocked + coins). */ + _setSkinShopData(data) { + const s = this._ensureSkinShopState(); + s.data = { ...s.data, ...data }; + s.rev++; + } + _onSkinBuyResult(res) { + const s = this._ensureSkinShopState(); + s.buyResult = { ...res, t: (this.scene?.getEngine?.()?.getFps?.() || 0) }; + s.rev++; + } + /** Намерение купить/надеть скин — шлём в runtime (он списывает рублики). */ + requestBuySkin(slug, price) { + const rt = this.gameRuntime; + if (!rt) return; + try { rt._handleCommand?.(null, 'player.buySkin', { slug, price: price || 0 }); } catch (e) {} + } + /** Данные из загруженных проектом кастомных .glb-скинов (data-URL по slug). */ + getAssetDataUrl(slug) { + try { + // Кастомные скины хранят dataUrl прямо в _skinsConfig.customGlbs. + const list = this._skinsConfig?.customGlbs || []; + const rec = list.find(g => g && g.slug === slug); + if (rec && rec.dataUrl) return rec.dataUrl; + } catch (e) {} + return null; + } + _onPlayerSkinChanged(slug) { + const s = this._ensureSkinShopState(); + if (s.data) { s.data.current = slug; s.rev++; } + } + + // ===== Библиотека пользовательских картинок (этап 3.6) ===== + + /** Список картинок проекта [{id, name, dataUrl}]. */ + getAssets() { + return this.assetManager ? this.assetManager.list() : []; + } + + /** Загрузить картинку из File. Возвращает Promise<{ok, id?, error?}>. */ + addAssetFromFile(file) { + if (!this.assetManager) return Promise.resolve({ ok: false, error: 'нет менеджера' }); + return this.assetManager.addFromFile(file).then((res) => { + if (res.ok && this._onSceneChange) this._onSceneChange(); + return res; + }); + } + + renameAsset(id, name) { + if (!this.assetManager) return; + this.assetManager.rename(id, name); + if (this._onSceneChange) this._onSceneChange(); + } + + /** + * Удалить картинку. Снимает её с примитивов/GUI, которые на неё ссылались — + * иначе остались бы «висячие» ссылки на несуществующий ассет. + */ + removeAsset(id) { + if (!this.assetManager) return; + this.assetManager.remove(id); + // Снять текстуру с примитивов, использовавших этот ассет. + if (this.primitiveManager) { + for (const data of this.primitiveManager.instances.values()) { + if (data.textureAsset === id) { + this.primitiveManager.updateInstance(data.id, { textureAsset: null }); + } + } + } + // Снять картинку с GUI-элементов image. + if (this.guiManager) { + for (const el of this.guiManager.getAll()) { + if (el.imageAsset === id) { + this.guiManager.update(el.id, { imageAsset: null }); + } + } + } + if (this._onSceneChange) this._onSceneChange(); + } + + // ===== Библиотека пользовательских звуков (Фаза 5.5) ===== + + /** Список звуков проекта [{id, name, dataUrl}]. */ + getSounds() { + return this.soundLibrary ? this.soundLibrary.list() : []; + } + + /** Загрузить звук из File. Возвращает Promise<{ok, id?, error?}>. */ + addSoundFromFile(file) { + if (!this.soundLibrary) return Promise.resolve({ ok: false, error: 'нет библиотеки' }); + return this.soundLibrary.addFromFile(file).then((res) => { + if (res.ok && this._onSceneChange) this._onSceneChange(); + return res; + }); + } + + renameSound(id, name) { + if (!this.soundLibrary) return; + this.soundLibrary.rename(id, name); + if (this._onSceneChange) this._onSceneChange(); + } + + removeSound(id) { + if (!this.soundLibrary) return; + this.soundLibrary.remove(id); + if (this._onSceneChange) this._onSceneChange(); + } + + // ===== Библиотека импортированных .glb-моделей (Фаза 5.8) ===== + + /** Список импортированных моделей [{id, name, dataUrl}]. */ + getGlbModels() { + return this.glbLibrary ? this.glbLibrary.list() : []; + } + + /** Загрузить .glb из File. Возвращает Promise<{ok, id?, error?}>. */ + addGlbFromFile(file) { + if (!this.glbLibrary) return Promise.resolve({ ok: false, error: 'нет библиотеки' }); + return this.glbLibrary.addFromFile(file).then((res) => { + if (res.ok && this._onSceneChange) this._onSceneChange(); + return res; + }); + } + + renameGlb(id, name) { + if (!this.glbLibrary) return; + this.glbLibrary.rename(id, name); + if (this._onSceneChange) this._onSceneChange(); + } + + removeGlb(id) { + if (!this.glbLibrary) return; + this.glbLibrary.remove(id); + if (this._onSceneChange) this._onSceneChange(); + } + + /** + * Колбэк после постановки нового объекта (блока/модели/примитива). + * Используется KubikonEditor чтобы переключить activeTool на 'select' + * и дать пользователю сразу таскать поставленный объект. + */ + setOnPostPlace(cb) { + this._onPostPlace = cb; + } + + /** + * Сохранить позиции всех unanchored объектов перед стартом физики. + * При exitPlayMode они возвращаются на эти позиции. + */ + _snapshotDynamicObjects() { + this._dynamicSnapshot = []; + if (this.blockManager) { + // Запоминаем позиции unanchored блоков (mesh-position). + // Сами блоки ОСТАЮТСЯ в blockManager.blocks Map, иначе вся остальная + // логика (сериализация, удаление, выделение) сломается. + // PhysicsAABB при Play фильтрует hasBlock через metadata.anchored + // и не считает unanchored клетку статичным препятствием. + for (const mesh of this.blockManager.blocks.values()) { + if (mesh.metadata?.anchored === false) { + this._dynamicSnapshot.push({ + kind: 'block', + mesh, + x: mesh.position.x, y: mesh.position.y, z: mesh.position.z, + rotX: mesh.rotation?.x || 0, + rotY: mesh.rotation?.y || 0, + rotZ: mesh.rotation?.z || 0, + }); + } + } + } + if (this.primitiveManager) { + for (const data of this.primitiveManager.instances.values()) { + if (data.anchored === false) { + this._dynamicSnapshot.push({ + kind: 'primitive', data, + x: data.x, y: data.y, z: data.z, + rotX: data.mesh?.rotation?.x || 0, + rotY: data.mesh?.rotation?.y || 0, + rotZ: data.mesh?.rotation?.z || 0, + }); + } + } + } + if (this.modelManager) { + for (const data of this.modelManager.instances.values()) { + if (data.anchored === false) { + this._dynamicSnapshot.push({ + kind: 'model', data, + x: data.x, y: data.y, z: data.z, + rotX: data.rootMesh?.rotation?.x || 0, + rotY: data.rootMesh?.rotation?.y || 0, + rotZ: data.rootMesh?.rotation?.z || 0, + }); + } + } + } + } + + _restoreDynamicObjects() { + if (!this._dynamicSnapshot) return; + for (const snap of this._dynamicSnapshot) { + if (snap.kind === 'block' && snap.mesh) { + snap.mesh.position.x = snap.x; + snap.mesh.position.y = snap.y; + snap.mesh.position.z = snap.z; + if (snap.mesh.rotation) { + snap.mesh.rotation.x = snap.rotX || 0; + snap.mesh.rotation.y = snap.rotY || 0; + snap.mesh.rotation.z = snap.rotZ || 0; + } + snap.mesh.setEnabled(true); + } else if (snap.kind === 'primitive' && snap.data) { + snap.data.x = snap.x; snap.data.y = snap.y; snap.data.z = snap.z; + if (snap.data.mesh) { + snap.data.mesh.position.set(snap.x, snap.y, snap.z); + if (snap.data.mesh.rotation) { + snap.data.mesh.rotation.x = snap.rotX || 0; + snap.data.mesh.rotation.y = snap.rotY || 0; + snap.data.mesh.rotation.z = snap.rotZ || 0; + } + snap.data.mesh.setEnabled(snap.data.visible !== false); + } + } else if (snap.kind === 'model' && snap.data) { + snap.data.x = snap.x; snap.data.y = snap.y; snap.data.z = snap.z; + if (snap.data.rootMesh) { + snap.data.rootMesh.position.set(snap.x, snap.y, snap.z); + if (snap.data.rootMesh.rotation) { + snap.data.rootMesh.rotation.x = snap.rotX || 0; + snap.data.rootMesh.rotation.y = snap.rotY || 0; + snap.data.rootMesh.rotation.z = snap.rotZ || 0; + } + snap.data.rootMesh.setEnabled(true); + } + } + } + this._dynamicSnapshot = null; + } + + /** + * Полный снимок сцены перед Play — примитивы и модели целиком. + * При exitPlayMode сцена восстанавливается ровно к этому состоянию: + * вернутся удалённые скриптом объекты, откатятся цвет/видимость/ + * коллизия/поворот, исчезнут заспавненные скриптом объекты. + * + * Зачем: скрипты игр меняют сцену деструктивно (game.self.delete, + * setColor, tween rotationY и т.д.). Без полного отката после + * Stop→Play сцена остаётся «использованной» — собранные монетки + * не появляются, открытая дверь остаётся открытой. Это как Stop + * в Roblox Studio: сцена возвращается к авторскому виду. + * + * Блоки СЮДА НЕ входят — их скрипты практически не меняют, а полная + * пересборка тысяч блоков дорогая. Падающие unanchored-блоки и так + * откатываются через _restoreDynamicObjects (позиции). + */ + _snapshotFullScene() { + this._fullSceneSnapshot = null; + try { + const snap = {}; + if (this.primitiveManager) { + snap.primitives = this.primitiveManager.serialize(); + } + if (this.modelManager) { + snap.models = this.modelManager.serialize(); + } + this._fullSceneSnapshot = snap; + } catch (e) { + console.warn('[BabylonScene] _snapshotFullScene не удался:', e); + this._fullSceneSnapshot = null; + } + } + + /** + * Восстановить сцену из полного снимка после Play. + * Пересоздаёт примитивы и модели точь-в-точь (id сохраняются — + * addInstance принимает opts.id, поэтому скрипты на объектах после + * рестарта снова найдут свои target.id). + */ + _restoreFullScene() { + if (!this._fullSceneSnapshot) return; + const snap = this._fullSceneSnapshot; + this._fullSceneSnapshot = null; + try { + // Сбрасываем выделение — loadFromArray диспозит старые mesh, + // selection не должен держать мёртвую ссылку. + try { this.selection?.clear?.(); } catch (e) {} + if (this.primitiveManager && Array.isArray(snap.primitives)) { + this.primitiveManager.loadFromArray(snap.primitives); + } + if (this.modelManager && Array.isArray(snap.models)) { + // loadFromArray у моделей async (модели грузятся с диска) — + // не ждём, восстановление догрузится в фоне. + Promise.resolve(this.modelManager.loadFromArray(snap.models)) + .catch((e) => console.warn('[BabylonScene] откат моделей:', e)); + } + } catch (e) { + console.warn('[BabylonScene] _restoreFullScene не удался:', e); + } + } + + /** Поменять anchored у выделенного объекта. */ + setSelectedAnchored(anchored) { + this.selection?.setSelectedAnchored(anchored); + } + + /** === Окружение / Время суток / Аудио / Вода === */ + setEnvironmentPreset(preset) { this.environment?.setPreset(preset); } + setTimeOfDay(hour) { this.environment?.setTimeOfDay(hour); } + setCycleDuration(dayMin, nightMin) { this.environment?.setCycleDuration(dayMin, nightMin); } + setFog(enabled, color, density) { this.environment?.setFog(enabled, color, density); } + getEnvironmentState() { return this.environment?.serialize() || null; } + + setAmbientAudio(opts) { this.audioManager?.setAmbient(opts); } + setMusicAudio(opts) { this.audioManager?.setMusic(opts); } + getAudioState() { return this.audioManager?.serialize() || null; } + + /** Доступные пресеты амбиента/музыки для UI. */ + getAudioPresets() { + return { ambient: AMBIENT_PRESETS || [], music: MUSIC_PRESETS || [] }; + } + + /** Доступные модели игрока (категория «Персонажи»). */ + getPlayerOptions() { + // Импорт MODEL_TYPES сложен из engine, поэтому берём через _playerOptionsCache + return this._playerOptionsCache || []; + } + setPlayerOptions(list) { this._playerOptionsCache = list; } + + /** Обновить пресет амбиента/музыки и обновить selection если открыт. */ + setSoundProps(patch) { + if (!patch) return; + if (patch.ambientId !== undefined) { + this.audioManager?.setAmbient({ preset: patch.ambientId }); + } + if (patch.musicId !== undefined) { + this.audioManager?.setMusic({ preset: patch.musicId }); + } + if (this.selection?._selection?.type === 'sound') { + this.selection.selectSound(); + } + } + + /** Обновить тип персонажа / силу прыжка / прицел. */ + setPlayerProps(patch) { + if (!patch) return; + if (patch.playerModelType) { + this.setPlayerModelType(patch.playerModelType); + } + if (typeof patch.jumpPower === 'number' && patch.jumpPower > 0) { + this.setPlayerJumpPower(patch.jumpPower); + } + if (typeof patch.crosshair === 'string') { + this.setCrosshair(patch.crosshair); + } + if (this.selection?._selection?.type === 'player') { + this.selection.selectPlayer(); + } + } + + /** Поменять mass у выделенного объекта. */ + setSelectedMass(mass) { + this.selection?.setSelectedMass(mass); + } + + /** Поменять свойства модели (canCollide / visible). */ + setSelectedModelProps(patch) { + if (!this.selection) return; + const sel = this.selection.getSelection(); + if (sel?.type === 'userModel') { + this.selection.setSelectedUserModelProps(patch); + return; + } + this.selection.setSelectedModelProps(patch); + } + + /** Поменять свойства блока (canCollide / visible). */ + setSelectedBlockProps(patch) { + this.selection?.setSelectedBlockProps(patch); + } + + /** === Папки/группы === */ + createFolder(name = 'Новая папка', parentId = null) { + return this.folderManager?.createFolder(name, parentId) ?? null; + } + renameFolder(id, name) { this.folderManager?.renameFolder(id, name); } + + /** Переименовать скрипт по id. Имя сохраняется в поле name. */ + renameScript(id, name) { + const i = this._scripts.findIndex(s => s.id === id); + if (i < 0) return false; + this._scripts[i] = { ...this._scripts[i], name: String(name || '').trim() || null }; + this.history?.markChange(); + if (this._onSceneChange) this._onSceneChange(); + return true; + } + + /** Переименовать инстанс модели. */ + renameModel(instanceId, name) { + const data = this.modelManager?.instances?.get(instanceId); + if (!data) return false; + data.name = String(name || '').trim() || null; + if (this._onSceneChange) this._onSceneChange(); + this.modelManager?._notifyChange?.(); + return true; + } + + /** Переименовать примитив. */ + renamePrimitive(id, name) { + const data = this.primitiveManager?.instances?.get(id); + if (!data) return false; + data.name = String(name || '').trim() || null; + if (this._onSceneChange) this._onSceneChange(); + this.primitiveManager?._notifyChange?.(); + return true; + } + removeFolder(id, deleteContent = false) { this.folderManager?.removeFolder(id, deleteContent); } + setFolderVisible(id, visible) { this.folderManager?.setVisible(id, visible); } + assignToFolder(kind, ref, folderId) { this.folderManager?.assignToFolder(kind, ref, folderId); } + /** Положить выделенное в указанную папку (или null = в корень). */ + assignSelectionToFolder(folderId) { + const sel = this.selection?.getSelection(); + if (!sel) return; + if (sel.type === 'block') { + this.folderManager?.assignToFolder('block', { x: sel.gridX, y: sel.gridY, z: sel.gridZ }, folderId); + } else if (sel.type === 'model') { + this.folderManager?.assignToFolder('model', sel.instanceId, folderId); + } else if (sel.type === 'primitive') { + this.folderManager?.assignToFolder('primitive', sel.id, folderId); + } + } + + /** Undo. */ + undo() { + return this.history?.undo(); + } + + /** Redo. */ + redo() { + return this.history?.redo(); + } + + /** Можно ли откатиться. */ + canUndo() { + return !!this.history?.canUndo(); + } + + /** Можно ли вернуть. */ + canRedo() { + return !!this.history?.canRedo(); + } + + /** + * Захватить превью-скриншот сцены как data URL (PNG, base64). + * Используется для иконки проекта в «Мои игры». + * size — размер квадратного превью в пикселях (по умолчанию 256). + */ + captureThumbnail(size = 256) { + if (!this.canvas) return null; + try { + // Простейший способ — взять текущий canvas-buffer и масштабировать его + // в новый offscreen canvas размера size×size. + const out = document.createElement('canvas'); + out.width = size; + out.height = size; + const ctx = out.getContext('2d'); + // Чёрная заливка на случай прозрачности + ctx.fillStyle = '#1a1410'; + ctx.fillRect(0, 0, size, size); + // Принудительный рендер чтобы backbuffer был свежим + if (this.scene) this.scene.render(); + // Сохраняем сохраняя пропорции — рисуем по короткой стороне + const sw = this.canvas.width, sh = this.canvas.height; + const minSide = Math.min(sw, sh); + const sx = (sw - minSide) / 2; + const sy = (sh - minSide) / 2; + ctx.drawImage(this.canvas, sx, sy, minSide, minSide, 0, 0, size, size); + return out.toDataURL('image/jpeg', 0.7); // JPEG-70% — ~10-30 КБ + } catch (e) { + // eslint-disable-next-line no-console + console.error('[BabylonScene] thumbnail error:', e); + return null; + } + } + + // === СОХРАНЕНИЕ И ЗАГРУЗКА =========================================== + + /** + * Сериализовать сцену в JSON-объект для сохранения в БД. + * Включает: блоки, модели, точку спавна, позицию редактор-камеры. + */ + /** + * Подготовить мини-карту для уже загруженного проекта (когда нет + * GeneratorParams). Считаем bbox реальных voxel'ов и сохраняем в + * window.__lastGenSize, чтобы MinimapOverlay масштабировался правильно. + * MinimapOverlay должна сама уметь рендерить real-data fallback. + */ + _setupMinimapForLoadedProject() { + if (!this.terrainManager || !this.terrainManager.voxels) return; + const voxels = this.terrainManager.voxels; + if (voxels.size === 0) return; + // Считаем bbox по X/Z. Берём max(|x|, |z|) как halfSize. + let maxAbs = 0; + for (const key of voxels.keys()) { + const lastComma = key.lastIndexOf(','); + const midComma = key.lastIndexOf(',', lastComma - 1); + const x = parseInt(key.slice(0, midComma), 10); + const z = parseInt(key.slice(lastComma + 1), 10); + const ax = Math.abs(x); + const az = Math.abs(z); + if (ax > maxAbs) maxAbs = ax; + if (az > maxAbs) maxAbs = az; + } + // maxAbs — в voxel-units. size для мини-карты = halfSize × 1.1 (запас). + const size = Math.ceil(maxAbs * 1.1); + window.__lastGenSize = size; + // Если ничего не было сгенерировано — НЕ ставим __lastGenParams. + // MinimapOverlay использует fallback на real-data top-down рендер. + console.log(`[BabylonScene] minimap configured for loaded project: half-size=${size} voxel-units (${(size * 2 * 0.25).toFixed(0)}м)`); + } + + /** + * Подготовить мини-карту для гладкого ландшафта (RobloxTerrain). + * + * Воксельная миникарта (_setupMinimapForLoadedProject) читает + * terrainManager.voxels — для smooth terrain их нет. Здесь публикуем + * ссылку на density-grid в window.__robloxMinimapGrid, а MinimapOverlay + * сам строит top-down heightmap из неё. + * + * Ставим _terrainStreamingEnabled=true чтобы MinimapOverlay стал visible + * (он показывается по этому флагу), даже если у гладкого ландшафта + * нет настоящего streaming-режима. + */ + _setupMinimapForRobloxTerrain() { + const grid = this._robloxTerrain?.grid; + if (!grid) return; + // CELL_SIZE=4м. Полная ширина карты в метрах. + const worldM = grid.size.x * 4; + // MinimapOverlay масштаб: getWorldViewM() = size*2*0.25. + // Чтобы worldViewM == worldM → size = worldM*2. + window.__lastGenSize = worldM * 2; + window.__lastGenParams = null; // не procedural-режим + window.__robloxMinimapGrid = grid; // density-grid для real-data рендера + this._terrainStreamingEnabled = true; // делает MinimapOverlay видимым + // ВАЖНО: _worldHalf по умолчанию 40 — на больших гладких картах это + // зажимало зомби/мобов в крошечный квадрат ±40 (они телепортировались + // к центру и проваливались). Подгоняем под реальный размер карты. + const half = Math.ceil(worldM / 2); + if (this._worldHalf < half) { + this._worldHalf = half; + // Синхронизируем physics.floorHalf — иначе игрок проваливается + // сквозь baseplate за пределами центральных 80×80. + if (this.physics) this.physics.floorHalf = half; + console.log(`[BabylonScene] _worldHalf -> ${half} (под размер гладкого ландшафта)`); + } + console.log(`[BabylonScene] minimap configured for RobloxTerrain: ${worldM}м (grid ${grid.size.x}×${grid.size.y}×${grid.size.z})`); + } + + /** + * Снять ТОЧНУЮ карту высот гладкого ландшафта (RobloxTerrain). + * + * Зачем: density-grid квантует высоту по 4м, а Surface Nets рендерит + * сглаженную поверхность между ячейками — реальная видимая высота + * отличается от грубой оценки по grid. Чтобы корректно ставить + * объекты/блоки на землю, нужна высота РЕАЛЬНОГО меша. + * + * Метод raycast'ит сверху-вниз по мешам RobloxTerrain в сетке точек + * (шаг step метров) и возвращает объект с картой высот. Используется + * билд-скриптами игр для точного размещения. + * + * @param {number} step шаг сетки в метрах (по умолчанию 2) + * @returns {object|null} { format, origin, worldSize, step, cols, rows, heights[] } + * heights — плоский массив (rows × cols), значение = Y поверхности или null. + */ + exportRobloxHeightmap(step = 2) { + const rt = this._robloxTerrain; + if (!rt || !rt.grid) { + console.warn('[exportRobloxHeightmap] нет гладкого ландшафта'); + return null; + } + const grid = rt.grid; + const CS = 4; // CELL_SIZE + // Мировые границы карты по grid. + const minWX = grid.origin.x * CS; + const minWZ = grid.origin.z * CS; + const worldX = grid.size.x * CS; + const worldZ = grid.size.z * CS; + const cols = Math.ceil(worldX / step) + 1; + const rows = Math.ceil(worldZ / step) + 1; + + // Предикат: только меши гладкого ландшафта. + const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain); + const heights = new Array(cols * rows).fill(null); + let hit = 0; + const t0 = performance.now(); + for (let r = 0; r < rows; r++) { + const wz = minWZ + r * step; + for (let c = 0; c < cols; c++) { + const wx = minWX + c * step; + const ray = new Ray(new Vector3(wx, 5000, wz), new Vector3(0, -1, 0), 10000); + const pick = this.scene.pickWithRay(ray, pickPred); + if (pick && pick.hit && pick.pickedPoint) { + heights[r * cols + c] = +pick.pickedPoint.y.toFixed(3); + hit++; + } + } + } + const dt = (performance.now() - t0).toFixed(0); + console.log(`[exportRobloxHeightmap] ${cols}×${rows} точек, ${hit} попаданий, ${dt}мс`); + + return { + format: 'roblox-heightmap-v1', + origin: { x: minWX, z: minWZ }, + worldSize: { x: worldX, z: worldZ }, + gridOrigin: { ...grid.origin }, + gridSize: { ...grid.size }, + step, + cols, + rows, + heights, + }; + } + + serialize() { + // Принадлежность объектов папкам — серилизуется в их собственных + // данных (folderId), а сами папки в отдельном массиве. + const blocksWithFolders = this.blockManager ? this.blockManager.serialize() : []; + // BlockManager.serialize не знает про folderId — добавляем его поверх. + if (this.blockManager) { + for (const item of blocksWithFolders) { + const mesh = this.blockManager.blocks.get(`${item.x},${item.y},${item.z}`); + item.folderId = mesh?.metadata?.folderId ?? null; + } + } + const modelsWithFolders = this.modelManager ? this.modelManager.serialize() : []; + if (this.modelManager) { + // Дописываем instanceId + folderId поверх стандартной сериализации + // (которая уже включает type/x/y/z/rotationY/anchored/canCollide/visible/mass) + const live = Array.from(this.modelManager.instances.values()); + for (let i = 0; i < modelsWithFolders.length && i < live.length; i++) { + modelsWithFolders[i].instanceId = live[i].instanceId; + modelsWithFolders[i].folderId = live[i].folderId ?? null; + } + } + const primitivesWithFolders = this.primitiveManager ? this.primitiveManager.getAll() : []; + if (this.primitiveManager) { + for (let i = 0; i < primitivesWithFolders.length; i++) { + const id = primitivesWithFolders[i].id; + const data = this.primitiveManager.instances.get(id); + primitivesWithFolders[i].folderId = data?.folderId ?? null; + } + } + // Terrain: RLE-формат для больших карт (×25 меньше чем legacy array). + // На карте 250м: ~1.5МБ вместо ~38МБ. + let terrainData; + if (this.terrainManager) { + const voxelCount = this.terrainManager.voxels?.size ?? 0; + if (voxelCount > 5000 && typeof this.terrainManager.serializeRLE === 'function') { + terrainData = this.terrainManager.serializeRLE(); + } else { + terrainData = this.terrainManager.serialize(); + } + } else { + terrainData = []; + } + // Roblox smooth terrain — отдельная подсистема, сериализуется параллельно + let robloxTerrainData = null; + if (this._robloxTerrain && this._robloxTerrain.grid) { + try { + robloxTerrainData = this._robloxTerrain.serialize(); + // Декорации сохраняем двумя способами: + // - decoParams (seed + density) — для процедурной генерации + // - decoInstances (полные матрицы) — для ручных правок plant-кистью + // При load — приоритет у decoInstances если они есть. + if (this._smoothDecoParams) { + robloxTerrainData.decoParams = this._smoothDecoParams; + } + if (this._smoothDecoManager) { + const stats = this._smoothDecoManager.getStats?.(); + if (stats && stats.total > 0) { + try { + robloxTerrainData.decoInstances = this._smoothDecoManager.serialize(); + } catch (e) { + console.warn('smoothDeco serialize failed:', e); + } + } + } + } catch (e) { console.warn('robloxTerrain serialize failed:', e); } + } + + return { + version: 1, + scene: { + blocks: blocksWithFolders, + models: modelsWithFolders, + primitives: primitivesWithFolders, + // Этап 5: пользовательские воксельные модели (созданные через + // ModelEditorScreen). Каждая запись: { type:'user:42', x,y,z, ry }. + userModels: this.userModelManager ? this.userModelManager.serialize() : [], + terrain: terrainData, + robloxTerrain: robloxTerrainData, + decorations: this.decoManager ? this.decoManager.serialize() : [], + folders: this.folderManager ? this.folderManager.serialize() : [], + gui: this.guiManager ? this.guiManager.serialize() : [], + inventory: this.inventory ? this.inventory.serialize() : null, + 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: чем больше карта, тем меньше radius. + // Поднял пороги (2026-05-27): на средних картах (1-3M voxels) + // streaming-radius 28-32м слишком мал для замкнутых объектов + // типа вулкана — игрок видит «полупрозрачные» стены, потому + // что дальние регионы стенки не материализованы. + let radius = 80; + if (finalVoxelCount > 5_000_000) radius = 40; + else if (finalVoxelCount > 3_000_000) radius = 55; + else if (finalVoxelCount > 1_000_000) radius = 70; + this._terrainStreamingRadius = radius; + this._terrainStreamingLastUpdate = 0; + // Автотуман для скрытия границы streaming. Без него видно + // резкий обрыв террейна на радиусе. + // + // Density подбираем по editor-radius: чем больше radius, + // тем дальше начало тумана. Для radius=72м: 0.005 — туман + // едва заметен в ближнем плане, но прячет обрыв на 70м. + try { + if (this.scene) { + const camY = this.camera?.position.y || 0; + const heightBonus = Math.max(0, Math.min(20, camY * 0.3)); + const effectiveRadius = Math.min(60, this._terrainStreamingRadius * 1.3 + heightBonus); + // Эмпирически: fogDensity ≈ 0.5/radius работает. + // На radius=72м → 0.007 (туман на 70-90м) + // На radius=40м → 0.0125 (туман на 40-60м, как раньше) + const density = Math.max(0.004, Math.min(0.014, 0.5 / effectiveRadius)); + this.scene.fogMode = 2; // FOGMODE_EXP + this.scene.fogColor = new Color3(0.55, 0.7, 0.85); // светло-голубой + this.scene.fogDensity = density; + } + } catch (e) {} + // Сразу первый pass с editor-radius формулой (см. render loop). + if (this.camera) { + const camY = this.camera.position.y || 0; + const heightBonus = Math.max(0, Math.min(20, camY * 0.3)); + const editorRadius = Math.min(60, this._terrainStreamingRadius * 1.3 + heightBonus); + const r = this.terrainManager.updateStreaming( + this.camera.position.x, this.camera.position.z, + editorRadius, + ); + console.log(`[BabylonScene] auto-streaming ON: ${r.enabled}/${r.total} regions visible (editor radius=${editorRadius.toFixed(0)}m, play radius=${this._terrainStreamingRadius}m)`); + } + this._setupMinimapForLoadedProject(); + } else { + console.log(`[BabylonScene] streaming NOT enabled (regionCount=0). Карта маленькая или region-split не сработал.`); + } + } + + // === Загрузка Roblox smooth terrain (параллельная подсистема) === + // Если в проекте есть robloxTerrain — создаём менеджер и загружаем grid. + const rts = state.scene.robloxTerrain; + if (rts && rts.format === 'robloxterrain-v1') { + try { + setProgress(90, 'Загрузка гладкого ландшафта…'); + if (!this._robloxTerrain) { + this._robloxTerrain = new RobloxTerrain(this.scene); + if (this.physics?.setRobloxTerrain) { + this.physics.setRobloxTerrain(this._robloxTerrain); + } + } + this._robloxTerrain.loadFromState(rts); + // Сразу материализуем chunks вокруг камеры + const camX = this.camera?.position.x || 0; + const camZ = this.camera?.position.z || 0; + const r = this._robloxTerrain.updateStreaming(camX, camZ, 99999, { maxBuild: 9999 }); + const stats = this._robloxTerrain.getStats(); + console.log(`[BabylonScene] LOAD robloxTerrain: ${r.built} chunks, ${stats.triangles} triangles`); + // Включаем мини-карту для гладкого ландшафта — MinimapOverlay + // показывается по флагу _terrainStreamingEnabled, а heightmap + // строит из density-grid (window.__robloxMinimapGrid). + this._setupMinimapForRobloxTerrain(); + // Если есть RobloxTerrain — отключаем baseplate-floor чтобы + // не создавал ложных коллизий под smooth-ландшафтом. + // НО только если рельеф большой (≥500 cells) — иначе baseplate + // нужен для визуального ориентирования и для plant-decos + // которые ставятся на y=0. + if (stats.solidCells >= 500) { + try { this.setFloorEnabled(false); } catch (e) {} + } + // === Загрузка smooth-decorations === + // Приоритет 1: decoInstances (точные матрицы, ручные правки plant-кистью) + // Приоритет 2: decoParams (seed-based процедурная генерация) + if (rts.decoInstances && rts.decoInstances.items?.length > 0) { + try { + if (!this._smoothDecoManager) { + this._smoothDecoManager = new SmoothDecoManager(this.scene); + } + const r = await this._smoothDecoManager.loadFromState(rts.decoInstances); + const total = r?.total ?? 0; + const tc = r?.treeColliders ?? []; + console.log(`[BabylonScene] LOAD smooth decorations (instances): ${total} (${tc.length} tree colliders)`); + if (this.physics?.setSmoothDecoTrees) { + this.physics.setSmoothDecoTrees(tc); + } + } catch (err) { + console.error('[BabylonScene] smooth decoInstances load failed:', err); + } + } else if (rts.decoParams) { + try { + const dp = rts.decoParams; + this._smoothDecoParams = dp; + if (!this._smoothDecoManager) { + this._smoothDecoManager = new SmoothDecoManager(this.scene); + } + await this._smoothDecoManager.loadAll(); + const decoGen = new WorldGenerator(dp.genParams); + const sampleSurfaceY = (x, z) => { + if (!this.physics?._sampleRobloxSurface) return null; + return this.physics._sampleRobloxSurface(x, z); + }; + const sampleBiomeId = (x, z) => { + const b = decoGen.sampleBiome(x * 4, z * 4); + return b?.id; + }; + const r2 = this._smoothDecoManager.placeDecorations({ + sampleSurfaceY, sampleBiomeId, bbox: dp.bbox, + densityFlowers: dp.flowersDensity, + densityGrass: dp.grassDensity, + densityTrees: dp.treesDensity ?? 0, + seed: dp.seed, + }); + console.log(`[BabylonScene] LOAD smooth decorations (params): ${r2.total} instances`); + if (this.physics?.setSmoothDecoTrees && r2.treeColliders) { + this.physics.setSmoothDecoTrees(r2.treeColliders); + } + } catch (err) { + console.error('[BabylonScene] smooth decorations load failed:', err); + } + } + } catch (err) { + console.error('[BabylonScene] robloxTerrain load failed:', err); + } + } + + setProgress(95, 'Размещение декораций…'); + // Этап 6: загрузка decorations (цветы/грибы/трава мини-вокселями). + if (this.decoManager && Array.isArray(state.scene.decorations)) { + this.decoManager.loadFromArray(state.scene.decorations); + // Этап D LOD: первый pass streaming для деко. + // maxBuild=2 — строим только 2 ближайших chunk сразу (карта появляется + // мгновенно). Остальные доступные подгружаются по 2-4 за тик + // (200мс), весь видимый набор за 1-2 секунды. UI не блокируется. + if (this.camera && this.decoManager.updateStreaming) { + const decoRadius = Math.max(18, (this._terrainStreamingRadius || 40) * 0.35); + const r = this.decoManager.updateStreaming( + this.camera.position.x, this.camera.position.z, decoRadius, + { maxBuild: 2 }, + ); + console.log(`[BabylonScene] deco streaming ON: ${r.visible}/${r.total} chunks visible (radius=${decoRadius.toFixed(0)}m)`); + } + } + + // Модели — асинхронно (GLB подгружаются) + if (this.modelManager && Array.isArray(state.scene.models)) { + await this.modelManager.loadFromArray(state.scene.models); + } + + // Этап 5: пользовательские воксельные модели — асинхронно + // (model_data грузится через API). Каждый item: {type:'user:42', x,y,z,ry,scale,canCollide,...}. + if (this.userModelManager && Array.isArray(state.scene.userModels) + && state.scene.userModels.length > 0) { + const loaded = await this.userModelManager.loadFromArray( + state.scene.userModels, + { currentUserId: this._currentUserId }, + ); + console.log(`[BabylonScene] user models loaded: ${loaded}/${state.scene.userModels.length}`); + // Регистрируем коллайдеры в физике + this._syncUserModelColliders(); + } + + // === Тип модели персонажа — РЕШАЕМ ДО предзагрузки/плеера === + // ВАЖНО: должно стоять ВЫШЕ _loadPrototype и до enterPlayMode, иначе + // PlayerController прочитает старый _playerModelType (баг: пончик 2046 + // не ставился — skins.default применялся ниже, после предзагрузки). + // Миграция: старые проекты сохраняли Kenney-модель ('character-a..g'); + // форсим R15 bacon-hair. Явно выбранные 'skin_*' не трогаем. + if (state.scene.playerModelType) { + const pmt = state.scene.playerModelType; + this._playerModelType = pmt.startsWith('character-') ? 'skin_bacon-hair' : 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; + } + + // ОПТИМИЗАЦИЯ: предзагружаем модель ИГРОКА (character) тоже — + // PlayerController.start() её ждёт, но если предзагрузить сейчас, + // на enterPlayMode она будет в кэше Babylon и стартует мгновенно. + // ВАЖНО: R15-скины ('skin_*') — отдельная система (characters// + // body.glb + манифест), ModelManager их не знает. Их грузит сам + // PlayerController через _loadSkinManifest — здесь пропускаем, + // иначе ModelManager пишет в консоль 'Unknown model type'. + try { + const playerModelType = this._playerModelType || 'character-a'; + if (!String(playerModelType).startsWith('skin_')) { + await this.modelManager?._loadPrototype?.(playerModelType); + } + } catch (e) { /* ignore */ } + + // Примитивы — синхронно + if (this.primitiveManager && Array.isArray(state.scene.primitives)) { + this.primitiveManager.loadFromArray(state.scene.primitives); + } + + // Папки + восстановление folderId на всех объектах + if (this.folderManager) { + this.folderManager.loadFromArray(state.scene.folders || []); + } + // GUI-элементы + if (this.guiManager) { + this.guiManager.loadFromArray(state.scene.gui || []); + } + // Инвентарь + if (this.inventory) { + this.inventory.loadFromArray(state.scene.inventory || null); + } + // Простановка folderId на блоках (loadFromArray BlockManager не знает про это поле) + if (this.blockManager && Array.isArray(state.scene.blocks)) { + for (const b of state.scene.blocks) { + if (b.folderId == null) continue; + const mesh = this.blockManager.blocks.get(`${b.x},${b.y},${b.z}`); + if (mesh && mesh.metadata) mesh.metadata.folderId = b.folderId; + } + } + if (this.modelManager && Array.isArray(state.scene.models)) { + // ModelManager.loadFromArray генерирует новые instanceId, + // поэтому folderId восстанавливаем по индексу (порядку). + const arr = state.scene.models; + const liveIds = Array.from(this.modelManager.instances.keys()); + for (let i = 0; i < arr.length && i < liveIds.length; i++) { + if (arr[i].folderId == null) continue; + const data = this.modelManager.instances.get(liveIds[i]); + if (data) data.folderId = arr[i].folderId; + } + } + if (this.primitiveManager && Array.isArray(state.scene.primitives)) { + // primitiveManager после loadFromArray генерирует новые id, поэтому + // восстановим folderId по индексу (порядку) — он совпадает. + const arr = state.scene.primitives; + const liveIds = Array.from(this.primitiveManager.instances.keys()); + for (let i = 0; i < arr.length && i < liveIds.length; i++) { + if (arr[i].folderId == null) continue; + const data = this.primitiveManager.instances.get(liveIds[i]); + if (data) data.folderId = arr[i].folderId; + } + } + // После расстановки folderId — применим эффективную видимость папок + if (this.folderManager) { + for (const f of this.folderManager.getAll()) { + this.folderManager._applyVisibility(f.id, this.folderManager._effectiveVisible(f.id)); + } + } + + // Зарегистрировать все объекты как shadow casters + this.refreshAllShadows(); + + // Точка спавна + if (state.scene.spawnPoint) { + this._spawnPoint = { ...state.scene.spawnPoint }; + this._updateSpawnMarker(); + } + // === Авто-fix спавна для smooth terrain === + // Если есть RobloxTerrain и spawnPoint оказался НИЖЕ поверхности — + // поднимаем его, чтобы игрок не провалился в момент нажатия "Запустить". + // Иначе raycast pickWithRay возвращает hit В ОБОИХ направлениях (mesh + // обволакивает AABB), физика застревает в UNSTUCK-цикле и игрок падает + // в бездну. + try { + if (this._robloxTerrain + && (this._robloxTerrain.getStats?.().solidCells ?? 0) > 0 + && this.physics?._sampleRobloxSurface) { + const surfaceY = this.physics._sampleRobloxSurface(this._spawnPoint.x, this._spawnPoint.z); + if (surfaceY !== null && this._spawnPoint.y < surfaceY + 1) { + const newY = surfaceY + 2; + console.log(`[BabylonScene] spawn auto-lifted: y ${this._spawnPoint.y.toFixed(2)} → ${newY.toFixed(2)} (surface=${surfaceY.toFixed(2)})`); + this._spawnPoint.y = newY; + this._updateSpawnMarker(); + } else if (surfaceY === null) { + console.warn('[BabylonScene] spawn auto-lift: no surface found under spawn'); + } + } + } catch (e) { console.warn('[BabylonScene] spawn auto-lift failed:', e); } + // (Тип модели персонажа и skins решены выше — до предзагрузки модели.) + // Пользовательские скрипты + if (Array.isArray(state.scene.scripts)) { + this._scripts = state.scene.scripts + .filter(s => s && typeof s.code === 'string') + .map(s => ({ + id: s.id || `script_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + code: s.code, + target: s.target || null, + name: s.name || null, + })); + } + // Окружение (время суток, скайбокс, туман) + if (state.scene.environment && this.environment) { + this.environment.load(state.scene.environment); + } + // Аудио (фоновая музыка/амбиент) + if (state.scene.audio && this.audioManager) { + this.audioManager.load(state.scene.audio); + } + + // Редактор-камера + if (state.editorCamera && this.camera) { + const c = state.editorCamera; + if (c.position) this.camera.position = new Vector3(c.position.x, c.position.y, c.position.z); + if (c.rotation) this.camera.rotation = new Vector3(c.rotation.x, c.rotation.y, c.rotation.z); + } + // Финальный прогресс — UI скроет overlay + if (typeof window !== 'undefined') { + window.__kubikonLoadProgress = { percent: 100, label: 'Готово!', ts: performance.now() }; + } + } + + /** + * Задача 08: активировать pointer-примитивы из палитры в реальные стрелки. + * Маркер-сфера прячется, через BeamManager создаётся анимированная стрелка + * (лента + парящий quest-marker) от источника к цели. from/to — из инспектора. + */ + _activatePointers() { + const pm = this.primitiveManager; + const bm = this.beamManager; + if (!pm || !bm) return; + for (const inst of pm.instances.values()) { + if (inst.type !== 'pointer') continue; + try { if (inst.mesh) inst.mesh.setEnabled(false); } catch (e) {} + const at = { x: inst.x, y: inst.y, z: inst.z }; + const from = this._pointerRefOrPoint(inst.pointerFrom, at); + const to = this._pointerRefOrPoint(inst.pointerTo, { x: at.x, y: at.y, z: at.z + 4 }); + try { + bm.addPointer({ + from, to, + preset: inst.pointerPreset || 'guide', + color: inst.color, textureSpeed: inst.textureSpeed, + curved: inst.curved, curveHeight: inst.curveHeight, + }); + } catch (e) { + console.warn('[BabylonScene] addPointer failed:', e); + } + } + } + + /** from/to стрелки: 'player' | id примитива/модели → ref | точка-fallback. */ + _pointerRefOrPoint(val, fallbackPoint) { + if (val === 'player') return 'player'; + if (val != null && val !== '') { + const n = Number(val); + if (Number.isFinite(n)) { + if (this.primitiveManager?.instances?.has(n)) return 'primitive:' + n; + if (this.modelManager?.instances?.has(n)) return 'model:' + n; + } + if (typeof val === 'string' + && (val.startsWith('primitive:') || val.startsWith('model:'))) return val; + } + return fallbackPoint; + } + + /** Выйти из режима игры — восстановить редактор-камеру. */ + exitPlayMode() { + if (!this._isPlaying) return; + this._isPlaying = false; + // Задача 04: закрываем любой активный модал чтобы не «висел» при стопе + try { this.modalManager?._instantClose?.(); } catch (e) {} + // Сбрасываем таймер прохождения + this._timerRunning = false; + this._timerStartedAt = null; + // Отключаем picking voxel-террейна обратно (нужно только в play). + try { this.terrainManager?.enablePickingForCamera?.(false); } catch (e) {} + // Размораживаем world-matrix статичных моделей — в редакторе + // пользователь может двигать их через гизмо. + try { this.modelManager?.unfreezeStaticModels?.(); } catch (e) {} + try { this.primitiveManager?.unfreezeStaticPrimitives?.(); } catch (e) {} + // Возвращаем все примитивы в видимое состояние (LOD-cull сбрасывается) + if (this.primitiveManager) { + for (const data of this.primitiveManager.instances.values()) { + const m = data.mesh; + if (m && m._kubikonPrimCulled === true) { + m.setEnabled(data.visible !== false); + m._kubikonPrimCulled = false; + } + } + } + + // Останавливаем пользовательские скрипты ПЕРЕД уничтожением player'а, + // чтобы скрипты не успели потрогать player в момент disposal. + if (this.gameRuntime) { + this.gameRuntime.stop(); + this.gameRuntime = null; + } + + // Placement mode (задача 11): сброс активной сессии + виджета магазина. + if (this.placementManager) { try { this.placementManager.dispose(); } catch (e) {} this.placementManager = null; } + if (this.shopInventoryUi) { try { this.shopInventoryUi.dispose(); } catch (e) {} this.shopInventoryUi = 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; + } + } +} diff --git a/src/engine/GameRuntime.js b/src/engine/GameRuntime.js index c376a49..19497be 100644 --- a/src/engine/GameRuntime.js +++ b/src/engine/GameRuntime.js @@ -1,3917 +1,3978 @@ -/** - * GameRuntime — управляет всеми пользовательскими скриптами в режиме Play. - * - * Жизненный цикл: - * const rt = new GameRuntime(scene3d); - * rt.setOnLog(({level,text}) => console.log(text)); - * rt.start(scripts); // scripts — массив { id, code } - * ... каждый кадр rt.tick(dt) ... - * rt.stop(); // выгрузить всех Worker'ов - * - * Каждый скрипт = отдельный Worker. Команды от Worker'ов обрабатываются здесь - * и применяются к BabylonScene (через player.teleport и т.п.). - * - * Этап 2.1: минимальный API — player.teleport, onTick, log. - */ - -import { Color3 } from '@babylonjs/core'; -import { ScriptSandbox } from './ScriptSandbox'; -import { STORYS_addres } from '../api/API'; - -export class GameRuntime { - constructor(scene3d) { - this.scene3d = scene3d; - /** @type {ScriptSandbox[]} */ - this.sandboxes = []; - this._onLog = null; - this._isRunning = false; - // Активные твины (game.tween). Крутятся в tick(dt). - // Каждый: { tweenId, scriptId, ref, props, from, duration, easing, - // delay, repeat, yoyo, elapsed, delayLeft, dir, loopsLeft } - this._tweens = []; - // Атрибуты объектов (game.scene.setData/getData). { ref: { key: value } }. - // Общие для всех скриптов, рассылаются воркерам через dataSnapshot. - this._objectData = {}; - // Интерактивные объекты (game.self.onInteract / ProximityPrompt). - // Каждый: { target, text, distance, key }. Заполняется при - // self.registerInteract, проверяется по дистанции в tick. - this._interactables = []; - // ref ближайшего интерактивного объекта в зоне (для подсветки [E]). - this._activeInteractRef = null; - // Общее состояние комнаты для game.room.set/get (Фаза 4.3). - // В редакторе (single-player) — локальное хранилище. С Colyseus- - // комнатой будет синхронизироваться (требует серверной схемы). - this._roomState = {}; - // Сессии игроков, которых видели в прошлом tick — для детекта - // join/leave (game.onPlayerJoin / onPlayerLeave). - this._seenSessions = null; - // Команды (Фаза 4.4): name → { name, color }. - this._teams = new Map(); - // Команда локального игрока (имя) или null. - this._localPlayerTeam = null; - } - - setOnLog(cb) { this._onLog = cb; } - - /** Колбэк HUD-команд от скриптов: { cmd, payload }. */ - setOnHud(cb) { this._onHud = cb; } - - /** Колбэк смены прицела через скрипт: (type) — UI обновляет overlay. */ - setOnCrosshairChange(cb) { this._onCrosshair = cb; } - - /** - * Запустить все скрипты. - * @param {Array<{id:any, code:string}>} scripts - */ - start(scripts) { - this.stop(); - this._isRunning = true; - // eslint-disable-next-line no-console - console.log('[GameRuntime] start called with scripts:', scripts); - if (!Array.isArray(scripts) || scripts.length === 0) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] start: no scripts to run'); - return; - } - // Карта модулей для game.require — { имя_скрипта: код }. - // Любой скрипт проекта можно подключить как модуль по его имени. - const modules = {}; - for (const s of scripts) { - if (s && typeof s.name === 'string' && s.name && typeof s.code === 'string') { - modules[s.name] = s.code; - } - } - // Первичный snapshot сцены — собираем СИНХРОННО ДО запуска скриптов и - // передаём прямо в init. Иначе findOne() в синхронном теле скрипта - // (на старте) возвращает null → подписки obj.onTouch/find не работают. - let initialScene = null; - try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; } - for (const s of scripts) { - if (!s || typeof s.code !== 'string' || !s.code.trim()) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] skipping invalid script entry', s); - continue; - } - const sb = new ScriptSandbox(s.code, s.target || null); - sb.scriptId = s.id; - sb.setModules(modules); - if (initialScene) sb.setInitialScene(initialScene); - // Если target есть — передаём начальную позицию self до старта - if (s.target) { - const pos = this._collectSelfPosition(s.target); - if (pos) sb.setInitialSelfPosition(pos); - } - sb.setOnCommand((cmd, payload) => { - // PERF-METRICS: замер скриптов (postMessage→handle) - const _t0 = performance.now(); - this._handleCommand(s.id, cmd, payload); - const m = this.scene3d?._perfMetrics; - if (m) { - m.script_ms_sum += performance.now() - _t0; - m.script_count++; - } - }); - sb.start(); - this.sandboxes.push(sb); - // eslint-disable-next-line no-console - console.log('[GameRuntime] sandbox started for script id=', s.id); - } - this._log('info', `Запущено скриптов: ${this.sandboxes.length}`); - // Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange' - // во все sandbox'ы. Не перезаписываем существующий обработчик — - // оборачиваем его (старый колбэк UI должен продолжать работать). - try { - const player = this.scene3d?.player; - if (player && !player._gameRuntimeHpHook) { - const prevCb = player._onHpChange; - this._lastSeenHp = player.hp ?? 100; - player._onHpChange = (ev) => { - if (typeof prevCb === 'function') { - try { prevCb(ev); } catch (e) {} - } - const delta = (ev?.hp ?? 0) - (this._lastSeenHp ?? 0); - this._lastSeenHp = ev?.hp ?? 0; - this.routeGlobalEvent('hpChange', { - hp: ev?.hp, - maxHp: ev?.maxHp, - source: ev?.source || null, - damaged: !!ev?.damaged, - delta, - }); - }; - player._gameRuntimeHpHook = true; - } - // Хуки прыжка/приземления для game.onPlayerJump / game.onPlayerLand - if (player && !player._gameRuntimeMoveHook) { - player._onJump = () => this.routeGlobalEvent('playerJump', {}); - player._onLand = () => this.routeGlobalEvent('playerLand', {}); - player._gameRuntimeMoveHook = true; - } - // Флаг для детекта смерти (game.onPlayerDied) — проверяется в tick - this._playerWasAlive = (this.scene3d?.player?.hp ?? 100) > 0; - // Хук смерти NPC (game.scene.onNpcDeath / npc.onDeath) — событие - // npcDeath с id и позицией погибшего NPC. - const nm = this.scene3d?.npcManager; - if (nm && typeof nm.setOnDeath === 'function') { - nm.setOnDeath((npcId, position) => { - this.routeGlobalEvent('npcDeath', { npcId, position }); - }); - } - } catch (e) { /* ignore */ } - // Первичный snapshot — нужен чтобы game.scene.find/all и game.gui.find работали с самого начала. - const sendInitial = () => { - this._broadcastSceneSnapshot(); - this._broadcastGuiSnapshot(); - this._broadcastTerrainHeightmap(); - this._broadcastSkinsSnapshot(); // задача 07 - // Задача 03: запустить animationPreset для GUI-элементов с пресетом ≠ 'none'. - this._startGuiAnimationPresets(); - }; - if (typeof requestAnimationFrame !== 'undefined') { - requestAnimationFrame(sendInitial); - } else { - setTimeout(sendInitial, 16); - } - } - - /** - * Разослать карту высот гладкого ландшафта всем sandbox'ам. - * Нужно для game.scene.surfaceY(x,z). Снимается raycast'ом по - * реальному мешу один раз — террейн в Play не меняется. - */ - _broadcastTerrainHeightmap() { - const s = this.scene3d; - if (!s || typeof s.exportRobloxHeightmap !== 'function') return; - // Шаг 3м — компромисс: меньше точек (~14K при 360м) чем у зомби - // (там шаг 2), для плавности движения животных достаточно. - let hm; - try { - hm = s.exportRobloxHeightmap(3); - } catch (e) { - return; - } - if (!hm || !hm.heights) return; - const payload = { - origin: hm.origin, step: hm.step, - cols: hm.cols, rows: hm.rows, heights: hm.heights, - }; - for (const sb of this.sandboxes) { - sb.sendTerrainHeightmap(payload); - } - } - - /** Задача 03: запустить пресеты анимаций для GUI-элементов в Play. */ - _startGuiAnimationPresets() { - const gm = this.scene3d?.guiManager; - if (!gm) return; - if (!this._guiTweens) this._guiTweens = []; - for (const el of (gm.elements || [])) { - const preset = el.animationPreset; - if (!preset || preset === 'none') continue; - const id = el.id; - // Каждый пресет = одна tween-запись с reverses+repeat=-1 - switch (preset) { - case 'pulse': - this._guiTweens.push(this._mkGuiPreset(id, el, - { scaleX: 1.1, scaleY: 1.1 }, 0.7, 'ease', true, -1)); - break; - case 'rotate': - this._guiTweens.push(this._mkGuiPreset(id, el, - { rotation: (el.rotation || 0) + 360 }, 3, 'linear', false, -1)); - break; - case 'sway': - this._guiTweens.push(this._mkGuiPreset(id, el, - { rotation: (el.rotation || 0) + 8 }, 0.6, 'ease', true, -1)); - this._guiTweens[this._guiTweens.length - 1].start.rotation = (el.rotation || 0) - 8; - break; - case 'glow': - this._guiTweens.push(this._mkGuiPreset(id, el, - { bgOpacity: 0.6 }, 0.8, 'ease', true, -1)); - break; - case 'bounce': - this._guiTweens.push(this._mkGuiPreset(id, el, - { y: (el.y || 50) - 1 }, 0.5, 'ease', true, -1)); - break; - } - } - } - _mkGuiPreset(id, el, targetProps, duration, easing, reverses, repeat) { - const start = {}; - for (const k of Object.keys(targetProps)) { - if (k === 'scaleX' || k === 'scaleY') start[k] = el[k] || 1; - else if (k === 'rotation') start[k] = el.rotation || 0; - else if (k === 'bgOpacity') start[k] = el.bgOpacity == null ? 1 : el.bgOpacity; - else start[k] = el[k] || 0; - } - return { - tweenId: ++this._tweenSeq || (this._tweenSeq = 1), - scriptId: '__preset__', - realId: id, - start, target: targetProps, - elapsed: 0, delay: 0, - duration, easing, - repeat, reverses, iter: 0, dir: 1, - }; - } - - /** - * Задача 07: разослать снапшот скинов всем sandbox'ам — чтобы - * game.player.getAvailableSkins/getAllSkins работали синхронно. - * Манифест грузится через fetch (кешируется браузером), затем - * объединяется с разблокированными скинами из scene.skins. - */ - async _broadcastSkinsSnapshot() { - try { - this._ensureSkinState(); - let manifest = this._skinManifestCache; - if (!manifest) { - const resp = await fetch('/kubikon-assets/characters/skins_manifest.json'); - const json = await resp.json(); - manifest = (json.skins || []).map(s => ({ - slug: s.slug || (s.id || '').replace(/^skin_/, ''), - name: s.name || s.slug, - kind: s.kind || 'r15', - category: s.category || 'human', - price: Number.isFinite(s.price) ? s.price : 0, - })); - // Встроенные «человеки» character-a..g тоже добавим как базовый выбор. - this._skinManifestCache = manifest; - } - const payload = { - all: manifest, - unlocked: Array.from(this._skinState.unlocked), - current: this._skinState.current, - coins: this._skinState.coins, - }; - for (const sb of this.sandboxes) sb.sendSkinsSnapshot(payload); - // Также отдать снапшот в scene для React-магазина. - try { this.scene3d?._setSkinShopData?.({ ...payload, manifestFull: this._skinManifestCache }); } catch (e) {} - } catch (e) { - // манифест недоступен — не критично, скрипт получит пустой список - } - } - - /** - * Задача 07: гарантированно инициализировать состояние скинов при первом - * обращении. Держит множество разблокированных скинов и текущий. - */ - _ensureSkinState() { - if (this._skinState) return this._skinState; - const sk = this.scene3d?._skinsConfig || {}; - const def = sk.default || this.scene3d?._playerModelType || 'character-a'; - const defSlug = this._slugFromTypeId(def); - const unlocked = new Set(Array.isArray(sk.unlocked) ? sk.unlocked : []); - unlocked.add(defSlug); - this._skinState = { - unlocked, - current: defSlug, - shopVisible: sk.shopVisible !== false, - coins: Number.isFinite(sk.coins) ? sk.coins : 0, - }; - return this._skinState; - } - - /** slug → _modelTypeId движка. Встроенные → 'skin_', character-* как есть. */ - _resolveSkinTypeId(slug) { - if (!slug) return 'character-a'; - if (slug.startsWith('character-')) return slug; - if (slug.startsWith('skin_') || slug.startsWith('customskin:')) return slug; - return 'skin_' + slug; - } - - /** _modelTypeId → slug (обратно). */ - _slugFromTypeId(typeId) { - if (!typeId) return 'character-a'; - if (typeId.startsWith('skin_')) return typeId.slice('skin_'.length); - return typeId; - } - - /** Задача 03: обновить GUI-твины (gui.tween + animationPresets). */ - _updateGuiTweens(dt) { - const gm = this.scene3d?.guiManager; - if (!gm) return; - for (let i = this._guiTweens.length - 1; i >= 0; i--) { - const tw = this._guiTweens[i]; - if (tw.delay > 0) { tw.delay -= dt; if (tw.delay > 0) continue; } - tw.elapsed += dt; - let t = tw.elapsed / tw.duration; - let done = false; - if (t >= 1) { t = 1; done = true; } - const raw = tw.dir === -1 ? 1 - t : t; - const k = GameRuntime._ease(tw.easing, raw); - // Применяем - const el = gm.elements.find(e => e.id === tw.realId); - if (!el) { this._guiTweens.splice(i, 1); continue; } - const patch = {}; - for (const key of Object.keys(tw.target)) { - const from = tw.start[key]; - const to = tw.target[key]; - if (typeof from === 'number' && typeof to === 'number') { - patch[key] = from + (to - from) * k; - } else if (typeof from === 'string' && typeof to === 'string' - && from.startsWith('#') && to.startsWith('#')) { - patch[key] = GameRuntime._lerpColor(from, to, k); - } else { - // Прочее — на конце ставим целевое - if (done) patch[key] = to; - } - } - // Throttle: обновляем не чаще чем раз в 32мс (~30 FPS). - tw._lastApply = tw._lastApply || 0; - tw._lastApply += dt; - if (tw._lastApply >= 0.032 || done) { - tw._lastApply = 0; - try { gm.update(tw.realId, patch); } catch (e) {} - } - - if (done) { - if (tw.reverses && tw.dir === 1) { - tw.dir = -1; - tw.elapsed = 0; - continue; - } - tw.iter++; - if (tw.repeat === -1 || tw.iter < tw.repeat) { - // повтор - tw.elapsed = 0; - tw.dir = 1; - continue; - } - // готово - this._guiTweens.splice(i, 1); - // onDone callback в worker - const sb = this.sandboxes.find(s => s.scriptId === tw.scriptId); - if (sb) sb.sendGlobalEvent({ type: 'tweenDone', tweenId: tw.tweenId }); - } - } - } - - /** Слить отложенные команды для конкретного только что зарезолвленного ref. */ - _drainPendingResolveQueue(resolvedLocalRef) { - if (!this._pendingResolveQueue || !this._pendingResolveQueue.length) return; - const stay = []; - for (const item of this._pendingResolveQueue) { - if (item.payload?.ref === resolvedLocalRef) { - this._handleCommand(item.scriptId, item.cmd, item.payload); - } else { - stay.push(item); - } - } - this._pendingResolveQueue = stay; - } - - /** - * Получить позицию объекта по его target (для зеркалирования в worker). - */ - _collectSelfPosition(target) { - if (!target || !this.scene3d) return null; - try { - if (target.kind === 'block') { - const r = target.ref || target; - return { x: r.x, y: r.y + 0.5, z: r.z }; - } - if (target.kind === 'model') { - const data = this.scene3d.modelManager?.instances?.get(target.id ?? target.ref); - if (data) return { x: data.x, y: data.y, z: data.z }; - } - if (target.kind === 'primitive') { - const data = this.scene3d.primitiveManager?.instances?.get(target.id ?? target.ref); - if (data) return { x: data.x, y: data.y, z: data.z }; - } - if (target.kind === 'userModel') { - const data = this.scene3d.userModelManager?.instances?.get(target.id ?? target.ref); - if (data) return { x: data.x, y: data.y, z: data.z }; - } - } catch (e) { /* ignore */ } - return null; - } - - stop() { - if (this.sandboxes.length > 0) { - this._log('info', 'Остановка скриптов'); - // eslint-disable-next-line no-console - console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes'); - for (const sb of this.sandboxes) sb.stop(); - } - // Удаляем все объекты, которые скрипты наспавнили через - // game.scene.spawn/clone — иначе после Stop они остаются на сцене - // и накапливаются при повторных запусках. - this._cleanupSpawnedObjects(); - // Удаляем GUI-элементы, созданные скриптом через game.gui.create — - // иначе после Stop они остаются в интерфейсе сцены. - this._cleanupSpawnedGui(); - // Убираем billboard-метки над объектами (game.scene.setLabel). - try { - if (this.scene3d?._labelManager) this.scene3d._labelManager.clearAll(); - } catch (e) { /* ignore */ } - this.sandboxes = []; - this._isRunning = false; - this._soloScriptId = null; - this._tweens = []; - this._objectData = {}; - this._interactables = []; - this._activeInteractRef = null; - this._watchedTouchRefs = null; - this._watchedClickRefs = null; - this._roomState = {}; - this._seenSessions = null; - this._teams = new Map(); - this._localPlayerTeam = null; - this._constraintLocalToReal = new Map(); - this._fxLocalToReal = new Map(); - this._soundLocalToReal = new Map(); - this._guiLocalToReal = new Map(); - this._guiRealToLocal = new Map(); - } - - /** - * Удалить GUI-элементы, созданные скриптом через game.gui.create. - * Вызывается в stop() — иначе скриптовый интерфейс остаётся в сцене - * после остановки игры и копится при повторных запусках. - */ - _cleanupSpawnedGui() { - if (!this._guiLocalToReal || this._guiLocalToReal.size === 0) return; - const s = this.scene3d; - if (!s || typeof s.removeGuiElement !== 'function') return; - for (const realId of this._guiLocalToReal.values()) { - try { - // removeGuiElement каскадно удаляет детей — повторный вызов - // для уже удалённого элемента безопасен (no-op). - s.removeGuiElement(realId); - } catch (e) { /* ignore */ } - } - // removeGuiElement дёргает _notify GuiManager → KubikonEditor - // синхронит guiList. Снапшот воркерам не нужен (они остановлены). - } - - /** Удалить со сцены все объекты, созданные скриптами в Play-режиме. */ - _cleanupSpawnedObjects() { - if (!this._localToReal || this._localToReal.size === 0) return; - const s = this.scene3d; - for (const realRef of this._localToReal.values()) { - try { - if (typeof realRef !== 'string') continue; - const colon = realRef.indexOf(':'); - if (colon < 0) continue; - const kind = realRef.slice(0, colon); - const rest = realRef.slice(colon + 1); - if (kind === 'block') { - const [xs, ys, zs] = rest.split(','); - s?.blockManager?.removeBlock(Number(xs), Number(ys), Number(zs)); - } else if (kind === 'model') { - s?.modelManager?.removeInstance(Number(rest)); - } else if (kind === 'primitive') { - s?.primitiveManager?.removeInstance(Number(rest)); - } - } catch (e) { /* ignore — объект мог быть уже удалён скриптом */ } - } - this._localToReal = new Map(); - } - - /** - * Запустить ОДИН скрипт без перезагрузки сцены — режим отладки. - * Останавливает другие скрипты, оставляет только заданный. - * Это альтернатива Play-режиму: без полноценного игрока, без физики, но - * скрипты получают зеркало state и могут вызывать game.log/teleport. - * - * Используется из ScriptEditor → кнопка «Запустить только этот». - */ - startSolo(script) { - this.stop(); - this._isRunning = true; - this._soloScriptId = script?.id || null; - if (!script || typeof script.code !== 'string' || !script.code.trim()) { - this._log('warn', 'Solo-запуск: пустой код'); - return; - } - const sb = new ScriptSandbox(script.code, script.target || null); - sb.scriptId = script.id; - if (script.target) { - const pos = this._collectSelfPosition(script.target); - if (pos) sb.setInitialSelfPosition(pos); - } - sb.setOnCommand((cmd, payload) => { - const _t0 = performance.now(); - this._handleCommand(script.id, cmd, payload); - const m = this.scene3d?._perfMetrics; - if (m) { - m.script_ms_sum += performance.now() - _t0; - m.script_count++; - } - }); - sb.start(); - this.sandboxes.push(sb); - this._log('info', `Отладочный запуск: ${script.id}`); - } - - /** True если runtime работает в solo-режиме (один скрипт). */ - isSolo() { return !!this._soloScriptId; } - getSoloScriptId() { return this._soloScriptId; } - - /** - * Вызывать каждый кадр в Play-режиме. - * dt в секундах. - */ - tick(dt) { - if (!this._isRunning || this.sandboxes.length === 0) return; - const state = this._collectState(); - for (const sb of this.sandboxes) { - // Для скриптов с target — добавляем актуальную позицию self - const stateForSb = sb.target - ? { ...state, selfPosition: this._collectSelfPosition(sb.target) } - : state; - sb.tick(dt, stateForSb); - } - // Анимации game.tween - if (this._tweens.length > 0) this._updateTweens(dt); - // Задача 03: GUI tweens - if (this._guiTweens && this._guiTweens.length > 0) this._updateGuiTweens(dt); - - // ProximityPrompt — подсказка [E] над ближайшим интерактивным объектом - if (this._interactables.length > 0) this._updateInteractables(); - - // Детект смерти игрока — событие game.onPlayerDied (один раз на смерть) - const hp = this.scene3d?.player?.hp ?? 100; - const aliveNow = hp > 0; - if (this._playerWasAlive && !aliveNow) { - this.routeGlobalEvent('playerDied', {}); - } - this._playerWasAlive = aliveNow; - - // Детект join/leave игроков комнаты (Фаза 4.3). - this._detectPlayerJoinLeave(state.players); - } - - /** - * Сравнить текущий список игроков с прошлым tick — событие - * playerJoin для новых, playerLeave для исчезнувших. - * Локального игрока не учитываем (он не «присоединяется»). - */ - _detectPlayerJoinLeave(players) { - if (!players || !players.list) return; - const now = new Map(); - for (const p of players.list) { - if (!p.isLocal) now.set(p.sessionId, p); - } - if (this._seenSessions == null) { - // Первый tick — фиксируем без событий (это «уже были»). - this._seenSessions = now; - return; - } - for (const [sid, p] of now) { - if (!this._seenSessions.has(sid)) { - this.routeGlobalEvent('playerJoin', { - sessionId: sid, name: p.name, - }); - } - } - for (const [sid, p] of this._seenSessions) { - if (!now.has(sid)) { - this.routeGlobalEvent('playerLeave', { - sessionId: sid, name: p.name, - }); - } - } - this._seenSessions = now; - } - - /** - * Запустить твин: зарезолвить ref, снять стартовые значения, добавить в _tweens. - * payload: { tweenId, ref, props, duration, easing, delay, repeat, yoyo } - */ - _startTween(scriptId, payload) { - try { - const { tweenId, ref, props } = payload || {}; - if (tweenId == null || typeof ref !== 'string' || !props) return; - const from = {}; - let guiId = null; - - // --- цель: GUI или 3D-объект --- - // GUI-id: либо локальный ref (gui.create), либо реальный id - let resolvedGuiId = ref; - if (this._guiLocalToReal?.has(ref)) resolvedGuiId = this._guiLocalToReal.get(ref); - const guiList = this.scene3d?.getGuiElements?.() || []; - const guiEl = guiList.find(g => g.id === resolvedGuiId); - - if (guiEl) { - guiId = resolvedGuiId; - // числовые свойства GUI - for (const key of ['x', 'y', 'w', 'h', 'bgOpacity', 'textSize']) { - if (props[key] != null && guiEl[key] != null) from[key] = Number(guiEl[key]); - } - // цвет - if (props.color != null && guiEl.bgColor) { - from._color = Color3.FromHexString(guiEl.bgColor); - from._colorTo = Color3.FromHexString(String(props.color)); - } - if (props.textColor != null && guiEl.textColor) { - from._color = Color3.FromHexString(guiEl.textColor); - from._colorTo = Color3.FromHexString(String(props.textColor)); - } - } else { - // 3D-объект - const tgt = this._resolveTweenTarget(ref); - if (!tgt) { - this._log('error', 'tween: объект не найден — ' + ref); - return; - } - const d = tgt.data; - from.x = d.x || 0; from.y = d.y || 0; from.z = d.z || 0; - from.rotationX = d.rotationX || 0; - from.rotationY = d.rotationY || 0; - from.rotationZ = d.rotationZ || 0; - from.sx = d.sx != null ? d.sx : 1; - from.sy = d.sy != null ? d.sy : 1; - from.sz = d.sz != null ? d.sz : 1; - from.opacity = d.opacity != null ? d.opacity - : (d.mesh?.material?.alpha != null ? d.mesh.material.alpha : 1); - if (props.color != null) { - const cur = d.color || '#ffffff'; - from._color = Color3.FromHexString(cur); - from._colorTo = Color3.FromHexString(String(props.color)); - } - } - - this._tweens.push({ - tweenId, scriptId, ref, guiId, - props, from, - duration: Math.max(0, Number(payload.duration) || 0), - easing: payload.easing || 'ease', - delayLeft: Math.max(0, Number(payload.delay) || 0), - loopsLeft: Number(payload.repeat) || 0, // 0=без повтора, -1=бесконечно - yoyo: !!payload.yoyo, - elapsed: 0, - dir: 1, - }); - } catch (e) { - this._log('error', 'tween.start failed: ' + (e?.message || e)); - } - } - - /** - * ProximityPrompt: каждый кадр ищем ближайший интерактивный объект - * в радиусе и показываем подсказку «[E] ...» над ним (HUD-метка). - */ - _updateInteractables() { - const player = this.scene3d?.player; - const pp = player?._pos; - if (!pp) return; - const halfH = player?.HALF_H ?? 0.9; - const px = pp.x, py = pp.y - halfH, pz = pp.z; - - let nearest = null; - let nearestD2 = Infinity; - for (const it of this._interactables) { - const objPos = this._resolveInteractPos(it); - if (!objPos) continue; - const dx = objPos.x - px, dy = objPos.y - py, dz = objPos.z - pz; - const d2 = dx*dx + dy*dy + dz*dz; - const r = it.distance; - if (d2 <= r*r && d2 < nearestD2) { - nearestD2 = d2; - nearest = it; - } - } - - const nearestRef = nearest ? nearest.ref : null; - if (nearestRef !== this._activeInteractRef) { - this._activeInteractRef = nearestRef; - if (nearest) { - // показываем подсказку через HUD (как game.ui.set) - if (this._onHud) { - try { - this._onHud({ cmd: 'ui.set', payload: { - id: '__interact', - text: '[' + nearest.key.toUpperCase() + '] ' + nearest.text, - opts: { x: 50, y: 75, color: '#ffe44a', size: 20 }, - } }); - } catch (e) { /* ignore */ } - } - } else { - // вышли из зоны — убираем подсказку - if (this._onHud) { - try { - this._onHud({ cmd: 'ui.set', payload: { id: '__interact', text: null } }); - } catch (e) { /* ignore */ } - } - } - } - } - - /** Резолв позиции интерактивного объекта (по ref). */ - _resolveInteractPos(it) { - const tgt = this._resolveTweenTarget(it.ref); - if (tgt) { - const d = tgt.data; - return { x: d.x || 0, y: d.y || 0, z: d.z || 0 }; - } - return null; - } - - /** - * Нажата клавиша взаимодействия (E) — отправить событие 'interact' - * скрипту ближайшего интерактивного объекта. Вызывается из routeGlobalEvent - * при keydown. - */ - _tryInteract(key) { - if (!this._activeInteractRef) return; - const it = this._interactables.find(x => x.ref === this._activeInteractRef); - if (!it || it.key !== String(key).toLowerCase()) return; - // событие 'interact' скрипту с target = этим объектом - this.routeEvent(it.target, 'interact', {}); - } - - /** Прокрутка всех активных твинов на dt секунд. */ - _updateTweens(dt) { - for (let i = this._tweens.length - 1; i >= 0; i--) { - const tw = this._tweens[i]; - // задержка перед стартом - if (tw.delayLeft > 0) { - tw.delayLeft -= dt; - if (tw.delayLeft > 0) continue; - dt = -tw.delayLeft; // остаток времени уходит в анимацию - } - tw.elapsed += dt; - let t = tw.duration > 0 ? tw.elapsed / tw.duration : 1; - let done = false; - if (t >= 1) { - t = 1; - done = true; - } - // прогресс с учётом направления (yoyo) + easing - const raw = tw.dir === -1 ? 1 - t : t; - const k = GameRuntime._ease(tw.easing, raw); - this._applyTweenFrame(tw, k); - - if (done) { - if (tw.yoyo && tw.dir === 1) { - // первый проход «туда» завершён — разворачиваем «обратно» - tw.dir = -1; - tw.elapsed = 0; - continue; - } - // цикл завершён полностью (или прямой, или yoyo туда-обратно) - if (tw.loopsLeft !== 0) { - if (tw.loopsLeft > 0) tw.loopsLeft--; - tw.dir = 1; - tw.elapsed = 0; - continue; - } - // твин закончен — снять и уведомить скрипт - this._tweens.splice(i, 1); - this._notifyTweenDone(tw.scriptId, tw.tweenId); - } - } - } - - /** Easing-функции. Принимают t∈[0,1], возвращают сглаженное значение. */ - static _ease(name, t) { - switch (name) { - case 'linear': - return t; - case 'bounce': { - const n1 = 7.5625, d1 = 2.75; - if (t < 1 / d1) return n1 * t * t; - if (t < 2 / d1) { t -= 1.5 / d1; return n1 * t * t + 0.75; } - if (t < 2.5 / d1) { t -= 2.25 / d1; return n1 * t * t + 0.9375; } - t -= 2.625 / d1; return n1 * t * t + 0.984375; - } - case 'elastic': { - if (t === 0 || t === 1) return t; - const c4 = (2 * Math.PI) / 3; - return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1; - } - case 'back': { - const c1 = 1.70158, c3 = c1 + 1; - return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); - } - case 'ease': - default: - // ease-in-out (плавный старт и финиш) - return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; - } - } - - /** Уведомить воркер скрипта что твин доиграл (resolve onDone). */ - _notifyTweenDone(scriptId, tweenId) { - const sb = this.sandboxes.find(s => s.scriptId === scriptId); - if (sb && sb.worker) { - try { sb.worker.postMessage({ cmd: 'tweenDone', payload: { tweenId } }); } catch (e) {} - } - } - - /** - * Сообщить ВСЕМ sandbox'ам маппинг локальный ref → реальный после - * scene.spawn. Нужно чтобы синхронные read-методы воркера - * (getPosition и т.п.) резолвили локальный ref в реальный — иначе - * заспавненный объект не находится в _sceneIndex (там реальные ref). - */ - _notifySpawnResolved(localRef, realRef) { - if (!localRef || !realRef) return; - // Объект мог быть удалён скриптом ДО того как зарезолвился - // (асинхронный спавн GLB-модели). Если он в очереди отложенных - // удалений — удаляем сейчас, когда реальный id известен. - if (this._pendingDeletes && this._pendingDeletes.has(localRef)) { - this._pendingDeletes.delete(localRef); - try { - this._applySceneDelete({ ref: realRef }); - } catch (e) { /* ignore */ } - return; - } - for (const sb of this.sandboxes) { - if (sb && sb.worker) { - try { - sb.worker.postMessage({ - cmd: 'spawnResolved', - payload: { localRef, realRef }, - }); - } catch (e) { /* ignore */ } - } - } - } - - /** - * Резолв ref в инстанс-данные объекта сцены. - * Возвращает { kind, data } или null. kind: 'primitive'|'model'|'userModel'. - * data — объект из *Manager.instances (имеет mesh/rootMesh/rootNode + x/y/z). - */ - /** - * Резолв id примитива из любого вида ссылки в реальный id для - * primitiveManager.instances. Принимает: - * - реальный числовой id (или строку-число) - * - локальный ref от spawn/clone ('primitive:_local_N') - * - ref 'primitive:realId' - * Возвращает id (число) или null. - */ - _resolvePrimitiveId(idOrRef) { - if (idOrRef == null) return null; - const pm = this.scene3d?.primitiveManager; - if (!pm) return null; - let v = idOrRef; - if (typeof v === 'string') { - // полный ref 'primitive:_local_N' / 'primitive:123' → резолвим через карту - if (this._localToReal?.has(v)) v = this._localToReal.get(v); - const colon = v.indexOf(':'); - if (colon >= 0) v = v.slice(colon + 1); - // голый '_local_N' (воркер мог отрезать 'primitive:') — ищем по карте: - // ключ 'primitive:_local_N' → значение 'primitive:realId'. - if (typeof v === 'string' && v.indexOf('_local_') === 0 && this._localToReal) { - const full = 'primitive:' + v; - if (this._localToReal.has(full)) { - const real = this._localToReal.get(full); - const c2 = real.indexOf(':'); - v = c2 >= 0 ? real.slice(c2 + 1) : real; - } - } - } - // прямой id - if (pm.instances.has(v)) return v; - const n = Number(v); - if (Number.isFinite(n) && pm.instances.has(n)) return n; - return null; - } - - /** - * ref NPC ('npc:_local_N' от воркера или 'npc:') → числовой npcId. - * Возвращает number или null. - */ - _resolveNpcId(ref) { - if (typeof ref !== 'string') return null; - let v = ref; - // Локальный ref воркера → реальный 'npc:'. - if (this._localToReal?.has(v)) v = this._localToReal.get(v); - const colon = v.indexOf(':'); - if (colon < 0) return null; - const id = Number(v.slice(colon + 1)); - return Number.isFinite(id) ? id : null; - } - - /** - * Выполнить NPC-команду. Если NPC ещё не создан (spawnNpc async, а - * скрипт сразу зовёт follow/moveTo/say) — откладываем команду в - * очередь по локальному ref и проигрываем после npcSpawned-резолва. - * Без этого команды сразу после spawnNpc молча терялись. - */ - _npcCmd(ref, fn) { - const nid = this._resolveNpcId(ref); - if (nid != null) { fn(nid); return; } - // ещё не резолвится — откладываем (только для локальных ref NPC) - if (typeof ref === 'string' && ref.indexOf('npc:_local_') === 0) { - if (!this._pendingNpcCmds) this._pendingNpcCmds = new Map(); - if (!this._pendingNpcCmds.has(ref)) this._pendingNpcCmds.set(ref, []); - this._pendingNpcCmds.get(ref).push(fn); - } - } - - /** Проиграть отложенные команды для NPC после его резолва. */ - _flushPendingNpcCmds(localRef, npcId) { - if (!this._pendingNpcCmds) return; - const queue = this._pendingNpcCmds.get(localRef); - if (!queue) return; - this._pendingNpcCmds.delete(localRef); - for (const fn of queue) { - try { fn(npcId); } catch (e) { /* ignore */ } - } - } - - /** Локальный ref связи ('constraint:_local_N') → числовой id или null. */ - _resolveConstraintId(ref) { - if (typeof ref !== 'string') return null; - if (this._constraintLocalToReal?.has(ref)) { - return this._constraintLocalToReal.get(ref); - } - // Запасной путь: прямой числовой id в строке. - const colon = ref.indexOf(':'); - const id = Number(colon >= 0 ? ref.slice(colon + 1) : ref); - return Number.isFinite(id) ? id : null; - } - - /** Локальный ref луча/следа ('fx:_local_N') → числовой id или null. */ - _resolveFxId(ref) { - if (typeof ref !== 'string') return null; - if (this._fxLocalToReal?.has(ref)) { - return this._fxLocalToReal.get(ref); - } - const colon = ref.indexOf(':'); - const id = Number(colon >= 0 ? ref.slice(colon + 1) : ref); - return Number.isFinite(id) ? id : null; - } - - _resolveTweenTarget(ref) { - if (typeof ref !== 'string') return null; - // Локальный ref из scene.spawn ('primitive:_local_N') → реальный id - if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref); - const colon = ref.indexOf(':'); - const kind = colon >= 0 ? ref.slice(0, colon) : null; - const rawId = colon >= 0 ? ref.slice(colon + 1) : ref; - const tryGet = (mgr) => { - if (!mgr || !mgr.instances) return null; - let d = mgr.instances.get(rawId); - if (!d) { - const n = Number(rawId); - if (Number.isFinite(n)) d = mgr.instances.get(n); - } - return d || null; - }; - if (kind === 'primitive' || kind == null) { - const d = tryGet(this.scene3d?.primitiveManager); - if (d) return { kind: 'primitive', data: d }; - } - if (kind === 'model' || kind == null) { - const d = tryGet(this.scene3d?.modelManager); - if (d) return { kind: 'model', data: d }; - } - const um = tryGet(this.scene3d?.userModelManager); - if (um) return { kind: 'userModel', data: um }; - return null; - } - - /** - * Применить промежуточное состояние твина к объекту. - * k — сглаженный прогресс [0,1]. Интерполяция from→props по каждому ключу. - */ - _applyTweenFrame(tw, k) { - const lerp = (a, b) => a + (b - a) * k; - // --- GUI-элемент --- - if (tw.guiId != null) { - const patch = {}; - for (const key of Object.keys(tw.props)) { - if (key === 'color' || key === 'textColor') continue; - if (tw.from[key] == null) continue; - patch[key] = lerp(tw.from[key], Number(tw.props[key])); - } - if (tw.props.color != null || tw.props.textColor != null) { - const ck = tw.props.color != null ? 'color' : 'textColor'; - patch[ck] = GameRuntime._lerpColor(tw.from._color, tw.from._colorTo, k); - } - // обновляем напрямую — без scheduleGuiSnapshot (дорого каждый кадр) - try { this.scene3d?.updateGuiElement?.(tw.guiId, patch); } catch (e) {} - return; - } - // --- 3D-объект --- - const tgt = this._resolveTweenTarget(tw.ref); - if (!tgt) return; - const d = tgt.data; - const p = tw.props, f = tw.from; - // позиция - let posChanged = false; - if (p.x != null) { d.x = lerp(f.x, Number(p.x)); posChanged = true; } - if (p.y != null) { d.y = lerp(f.y, Number(p.y)); posChanged = true; } - if (p.z != null) { d.z = lerp(f.z, Number(p.z)); posChanged = true; } - // поворот - let rotChanged = false; - if (p.rotationX != null) { d.rotationX = lerp(f.rotationX || 0, Number(p.rotationX)); rotChanged = true; } - if (p.rotationY != null) { d.rotationY = lerp(f.rotationY || 0, Number(p.rotationY)); rotChanged = true; } - if (p.rotationZ != null) { d.rotationZ = lerp(f.rotationZ || 0, Number(p.rotationZ)); rotChanged = true; } - // масштаб - let scaleChanged = false; - if (p.sx != null) { d.sx = lerp(f.sx || 1, Number(p.sx)); scaleChanged = true; } - if (p.sy != null) { d.sy = lerp(f.sy || 1, Number(p.sy)); scaleChanged = true; } - if (p.sz != null) { d.sz = lerp(f.sz || 1, Number(p.sz)); scaleChanged = true; } - // меш (primitive → .mesh, model/userModel → .rootMesh/.rootNode) - const mesh = d.mesh || d.rootMesh || d.rootNode; - if (mesh) { - if (posChanged && mesh.position) mesh.position.set(d.x, d.y, d.z); - if (rotChanged && mesh.rotation) { - mesh.rotation.x = d.rotationX || 0; - mesh.rotation.y = d.rotationY || 0; - mesh.rotation.z = d.rotationZ || 0; - } - if (scaleChanged && mesh.scaling) { - mesh.scaling.set(d.sx || 1, d.sy || 1, d.sz || 1); - } - // размороз world-matrix если был заморожен - if ((posChanged || rotChanged || scaleChanged) && d._worldMatrixFrozen) { - try { mesh.unfreezeWorldMatrix?.(); } catch (e) {} - d._worldMatrixFrozen = false; - } - } - // цвет - if (p.color != null && f._color != null && mesh?.material) { - const c = GameRuntime._lerpColor3(f._color, f._colorTo, k); - mesh.material.diffuseColor = c; - if (d.material === 'neon') mesh.material.emissiveColor = c; - d.color = '#' + c.toHexString().slice(1); - } - // прозрачность - if (p.opacity != null && mesh?.material) { - const op = lerp(f.opacity != null ? f.opacity : 1, Number(p.opacity)); - mesh.material.alpha = op; - d.opacity = op; - } - } - - /** Интерполяция цвета (Babylon Color3) между двумя hex. */ - static _lerpColor3(from, to, k) { - return new Color3( - from.r + (to.r - from.r) * k, - from.g + (to.g - from.g) * k, - from.b + (to.b - from.b) * k, - ); - } - - /** Интерполяция цвета → hex-строка (для GUI). */ - static _lerpColor(from, to, k) { - return '#' + GameRuntime._lerpColor3(from, to, k).toHexString().slice(1); - } - - /** - * Маршрутизация событий объектов к скриптам с соответствующим target. - * Вызывается из BabylonScene при клике/touch. - * - * @param {object} target — {kind, ref|x|y|z|id} - * @param {string} eventType — 'click' | 'touch' - * @param {object} extra — дополнительные данные события - */ - routeEvent(target, eventType, extra = {}) { - if (!target || !eventType) return; - for (const sb of this.sandboxes) { - if (!sb.target) continue; - if (!this._targetMatches(sb.target, target)) continue; - sb.sendEvent({ type: eventType, ...extra }); - } - } - - /** - * Глобальное событие — доставляется ВСЕМ sandbox'ам (не зависит от target). - * Используется для onKey, onClick (глобальный), onPlayerTouch. - */ - routeGlobalEvent(eventType, extra = {}) { - if (!eventType) return; - // Спецслучай: guiClick приходит с realId. Worker мог подписаться двумя - // способами: - // 1) по локальному ref, который вернул gui.create() — '_gui_local_N' - // 2) по ЯВНОМУ id, который сам передал в gui.create({ id: 'fight' }), - // или по name элемента. - // Раньше мы ПЕРЕЗАПИСЫВАЛИ extra.id на localRef — это ломало вариант (2), - // потому что worker искал handler по localRef, а юзер подписался по - // явному id. Теперь шлём ОБА id (`id` = реальный + `localId` = ref), - // worker матчит по обоим (см. _guiHandlerKeys в ScriptSandboxWorker). - if ((eventType === 'guiClick' || eventType === 'guiSubmit' - || eventType === 'guiTextChange') - && extra && extra.id != null && this._guiRealToLocal) { - const local = this._guiRealToLocal.get(extra.id); - if (local && local !== extra.id) extra = { ...extra, localId: local }; - } - // ProximityPrompt: keydown клавиши взаимодействия → событие interact - if (eventType === 'keydown' && extra && extra.key - && this._interactables.length > 0) { - this._tryInteract(extra.key); - } - for (const sb of this.sandboxes) { - sb.sendGlobalEvent({ type: eventType, ...extra }); - } - } - - /** - * Адресное событие касания/клика КОНКРЕТНОГО объекта по его ref. - * Доставляется всем sandbox'ам как globalEvent с type='instTouch'|... + ref; - * worker матчит по ref на findOne(x).onTouch/onUntouch/onClick. - */ - routeInstEvent(ref, type, extra = {}) { - if (!ref || !type) return; - this.routeGlobalEvent(type, { ref, ...extra }); - } - - /** - * Уведомление: моб убит. Рассылается во все скрипты как 'mobKilled'. - * Скрипт может подписаться через `game.onMobKilled(fn)`. - * payload: { type: 'zombie' | ..., x, y, z } - */ - notifyMobKilled(mobType, position) { - this.routeGlobalEvent('mobKilled', { mobType, position }); - } - - /** Совпадает ли target скрипта с обращённым target события. */ - _targetMatches(a, b) { - if (!a || !b) return false; - if (a.kind !== b.kind) return false; - if (a.kind === 'block') { - const ar = a.ref || a; - const br = b.ref || b; - return ar.x === br.x && ar.y === br.y && ar.z === br.z; - } - const aId = a.id ?? a.ref; - const bId = b.id ?? b.ref; - return aId === bId; - } - - /** Собрать снимок state для отправки в Worker'ы. */ - _collectState() { - const player = this.scene3d?.player; - // PlayerController хранит позицию в this._pos (Vector3). - // Внутри _pos.y — это центр капсулы (учтена HALF_H ~= 0.9), для авторов - // удобнее давать «низ ног» = _pos.y - HALF_H. - const p = player?._pos; - const halfH = player?.HALF_H ?? 0.9; - const position = p - ? { x: p.x, y: p.y - halfH, z: p.z } - : { x: 0, y: 0, z: 0 }; - // Yaw/pitch (для player.forward) - const yaw = player?._yaw || 0; - const pitch = player?._pitch || 0; - // Forward-вектор. PlayerController использует: - // fx = sin(yaw)*cos(pitch), fy = -sin(pitch), fz = cos(yaw)*cos(pitch) - const cosP = Math.cos(pitch); - const forward = { - x: Math.sin(yaw) * cosP, - y: -Math.sin(pitch), - z: Math.cos(yaw) * cosP, - }; - const crosshair = this.scene3d?.getCrosshair ? this.scene3d.getCrosshair() : 'none'; - const hp = player?.hp ?? 100; - const maxHp = player?.maxHp ?? 100; - // Снимок мобов (зомби) — для game.scene.mobs() из скриптов - let mobs = []; - try { - const zm = this.scene3d?.zombieManager; - if (zm && typeof zm.getMobsSnapshot === 'function') { - mobs = zm.getMobsSnapshot(); - } - } catch (e) {} - // Снимок NPC — для game.scene.npcs() и npc.position из скриптов. - let npcs = []; - try { - const nm = this.scene3d?.npcManager; - if (nm && typeof nm.getSnapshot === 'function') { - npcs = nm.getSnapshot(); - } - } catch (e) {} - // Снимок инвентаря — для game.inventory.has/list/active. - let inventory = null; - try { - const inv = this.scene3d?.inventory; - if (inv) { - inventory = { - slots: inv.slots.map(s => s ? { - kind: s.kind, modelTypeId: s.modelTypeId, name: s.name, - } : null), - activeIndex: inv.activeIndex, - }; - } - } catch (e) {} - // Снимок игроков комнаты — для game.players.* (Фаза 4.3). - // В редакторе (single-player) — только локальный игрок. - // С мультиплеером — локальный + все remote из _mpSync. - const players = this._collectPlayers(position, hp, maxHp); - // Кубикон Dash: текущее направление гравитации (+1 / -1). - // Нужно скрипту для рендера куба в правильной ориентации. - const gravityDir = player?._gravityDir ?? 1; - // Состояние игрока ('ground'|'air'|'water') для game.player.state. - const state = player?._playerState || 'ground'; - // Зажатые клавиши — для game.player.isKeyDown(key). - // _codes хранит коды ('KeyW','Space','ArrowUp'), нормализуем в имена скрипта. - const keys = {}; - if (player?._codes) { - for (const code of player._codes) { - const k = GameRuntime._normalizeKeyCode(code); - if (k) keys[k] = true; - } - } - return { - player: { position, yaw, pitch, forward, crosshair, hp, maxHp, gravityDir, state, keys }, - mobs, - npcs, - inventory, - players, - roomState: this._roomState || {}, - teams: this._teams ? Array.from(this._teams.values()) : [], - }; - } - - /** - * Снимок всех игроков комнаты для game.players.* (Фаза 4.3). - * Локальный игрок всегда первый, sessionId='local' в одиночной игре - * или реальный sessionId если есть Colyseus-комната. - * Возвращает { me, list } — list включает me. - */ - _collectPlayers(myPos, myHp, myMaxHp) { - const mp = this.scene3d?._mpSync; - const mySessionId = mp?.room?.sessionId || 'local'; - const myName = mp?.room?.state?.players?.get?.(mySessionId)?.username - || this._localPlayerName || 'Игрок'; - const me = { - sessionId: mySessionId, - name: myName, - isLocal: true, - position: myPos, - hp: myHp, maxHp: myMaxHp, - team: this._localPlayerTeam || null, - }; - const list = [me]; - // Remote-игроки из MultiplayerSync (если есть комната). - if (mp && mp.remotePlayers) { - const roomPlayers = mp.room?.state?.players; - for (const rp of mp.remotePlayers.values()) { - // team берётся из Colyseus-state (его синхронизирует сервер). - const colyP = roomPlayers?.get?.(rp.sessionId); - list.push({ - sessionId: rp.sessionId, - name: rp.username || rp.sessionId, - isLocal: false, - position: rp.current - ? { x: rp.current.x, y: rp.current.y, z: rp.current.z } - : { x: 0, y: 0, z: 0 }, - hp: rp.hp ?? 100, maxHp: rp.maxHp ?? 100, - team: (colyP && colyP.team) || null, - }); - } - } - return { me, list }; - } - - /** Код клавиши Babylon ('KeyW','Space','ArrowUp') → имя для скрипта ('w','space','arrowup'). */ - static _normalizeKeyCode(code) { - if (!code) return null; - if (code.startsWith('Key')) return code.slice(3).toLowerCase(); // KeyW → w - if (code.startsWith('Digit')) return code.slice(5); // Digit1 → 1 - if (code.startsWith('Arrow')) return code.toLowerCase(); // ArrowUp → arrowup - const map = { - Space: 'space', ShiftLeft: 'shift', ShiftRight: 'shift', - Enter: 'enter', Escape: 'escape', - ControlLeft: 'ctrl', ControlRight: 'ctrl', - }; - return map[code] || code.toLowerCase(); - } - - /** Команда от Worker'а пришла — применяем на сцене. */ - _handleCommand(scriptId, cmd, payload) { - if (cmd === 'log') { - this._log(payload?.level || 'info', payload?.text || ''); - return; - } - // inst.watchTouch / inst.watchClick — скрипт подписался на касание/клик - // ПРОИЗВОЛЬНОГО объекта (findOne(x).onTouch/onClick). Движок начинает - // следить за AABB этого объекта в _detectTouchEvents и слать обратно - // instTouch/instUntouch (через routeInstEvent). - if (cmd === 'inst.watchTouch') { - const ref = payload && payload.ref; - if (typeof ref === 'string') { - if (!this._watchedTouchRefs) this._watchedTouchRefs = new Set(); - this._watchedTouchRefs.add(ref); - } - return; - } - if (cmd === 'inst.watchClick') { - const ref = payload && payload.ref; - if (typeof ref === 'string') { - if (!this._watchedClickRefs) this._watchedClickRefs = new Set(); - this._watchedClickRefs.add(ref); - } - return; - } - if (cmd === 'player.teleport') { - const player = this.scene3d?.player; - if (player && player._pos && payload) { - const { x, y, z } = payload; - if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) { - try { - const halfH = player.HALF_H ?? 0.9; - // Конвертируем «низ ног» обратно в центр капсулы - player._pos.set(x, y + halfH, z); - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] teleport failed', e); - } - } - } else { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] teleport ignored — no player or _pos', { hasPlayer: !!player, hasPos: !!(player && player._pos) }); - } - return; - } - if (cmd === 'player.setLaneX') { - // Сдвиг игрока ТОЛЬКО по X — не трогает Z и Y. Нужно для - // раннеров (смена полосы): teleport(x,y,z) затирал бы Z, - // отменяя продвижение autorun каждый кадр. - const player = this.scene3d?.player; - if (player && player._pos && payload) { - const x = Number(payload.x); - if (Number.isFinite(x)) { - try { player._pos.x = x; } catch (e) { /* ignore */ } - } - } - return; - } - if (cmd === 'player.damage') { - const player = this.scene3d?.player; - if (player && typeof player.takeDamage === 'function') { - const amt = Math.max(0, Number(payload?.amount) || 0); - if (amt > 0) { - // Если урон больше maxHp — обходим i-frames для kill(). - if (amt >= (player.maxHp ?? 100)) { - player._lastDamageTime = 0; // сбрасываем cooldown - } - try { player.takeDamage(amt, 'script'); } catch (e) {} - } - } - return; - } - if (cmd === 'player.heal') { - const player = this.scene3d?.player; - if (player && typeof player.hp === 'number') { - const amt = Math.max(0, Number(payload?.amount) || 0); - player.hp = Math.min(player.maxHp ?? 100, player.hp + amt); - if (player._onHpChange) { - try { player._onHpChange({ hp: player.hp, maxHp: player.maxHp, source: 'heal', damaged: false }); } catch (e) {} - } - } - return; - } - if (cmd === 'player.respawn') { - const player = this.scene3d?.player; - if (player && player._pos) { - // Восстанавливаем HP - player.hp = player.maxHp ?? 100; - if (player._onHpChange) { - try { player._onHpChange({ hp: player.hp, maxHp: player.maxHp, source: 'respawn', damaged: false }); } catch (e) {} - } - // Возвращаем модель если была спрятана при смерти - if (player._modelRoot) player._modelRoot.setEnabled(true); - // Телепорт на spawnPoint сцены - const sp = this.scene3d?._spawnPoint - || this.scene3d?.scene?.metadata?.spawnPoint - || { x: 0, y: 1, z: 0 }; - const halfH = player.HALF_H ?? 0.9; - try { player._pos.set(sp.x, sp.y + halfH, sp.z); } catch (e) {} - // Сбросим скорость падения - if (player._velocity) { - try { player._velocity.set(0, 0, 0); } catch (e) {} - } - } - return; - } - if (cmd === 'player.setSpawn') { - // Назначить активную точку возрождения. Меняем scene3d._spawnPoint — - // им пользуется player.respawn и логика смерти. - const s = this.scene3d; - if (s && payload) { - let sp = null; - if (typeof payload.ref === 'string') { - // ref объекта: встаём НАД ним (центр + полувысота + зазор). - const ref = payload.ref; - if (ref.indexOf('block:') === 0) { - const [bx, by, bz] = ref.slice(6).split(',').map(Number); - if ([bx, by, bz].every(Number.isFinite)) { - sp = { x: bx, y: by + 1.1, z: bz }; - } - } else { - const tgt = this._resolveTweenTarget(ref); - if (tgt && tgt.data) { - const d = tgt.data; - const topOff = (d.sy != null ? d.sy * 0.5 : 0.5) + 0.1; - sp = { x: d.x, y: (d.y || 0) + topOff, z: d.z }; - } - } - } else if (Number.isFinite(payload.x)) { - sp = { x: payload.x, y: payload.y, z: payload.z }; - } - if (sp && typeof s.setSpawnPoint === 'function') { - s.setSpawnPoint(sp.x, sp.y, sp.z); - } - } - return; - } - // === NPC API (Фаза 4.1) === - if (cmd === 'npc.spawn') { - // payload: { modelType, ref, x, y, z, rotationY, hp, name, speed } - const nm = this.scene3d?.npcManager; - if (nm && payload) { - if (!this._localToReal) this._localToReal = new Map(); - const p = nm.spawnNpc(payload.modelType, { - x: payload.x, y: payload.y, z: payload.z, - rotationY: payload.rotationY, - hp: payload.hp, name: payload.name, speed: payload.speed, - }); - Promise.resolve(p).then((npcId) => { - if (npcId == null) { - this._log('error', 'spawnNpc не удался: ' + payload.modelType); - return; - } - // Локальный ref воркера → реальный 'npc:'. - if (payload.ref) { - this._localToReal.set(payload.ref, 'npc:' + npcId); - // Проигрываем команды, отправленные скриптом сразу - // после spawnNpc (follow/moveTo/say) — они ждали - // резолва ref в очереди. - this._flushPendingNpcCmds(payload.ref, npcId); - } - // Сообщаем воркеру маппинг localRef → npcId, чтобы - // npc.onDeath по локальному ref находил правильного NPC. - if (payload.ref) { - const sb = this.sandboxes.find(s => s.scriptId === scriptId); - if (sb && sb.worker) { - try { - sb.worker.postMessage({ - cmd: 'npcSpawned', - payload: { localRef: payload.ref, npcId }, - }); - } catch (e) { /* ignore */ } - } - } - }).catch((err) => { - this._log('error', 'spawnNpc failed: ' + (err?.message || err)); - }); - } - return; - } - if (cmd === 'npc.moveTo') { - // _npcCmd откладывает команду, если NPC ещё не создан (async). - this._npcCmd(payload?.ref, (nid) => - this.scene3d?.npcManager?.moveTo(nid, payload.x, payload.z)); - return; - } - if (cmd === 'npc.follow') { - this._npcCmd(payload?.ref, (nid) => { - // target — ref объекта или 'player'. Резолвим локальный ref - // в реальный (объект мог быть заспавнен скриптом). - let target = payload?.target; - if (typeof target === 'string' && this._localToReal?.has(target)) { - target = this._localToReal.get(target); - } - this.scene3d?.npcManager?.follow(nid, target); - }); - return; - } - if (cmd === 'npc.stop') { - this._npcCmd(payload?.ref, (nid) => - this.scene3d?.npcManager?.stopNpc(nid)); - return; - } - if (cmd === 'npc.setSpeed') { - this._npcCmd(payload?.ref, (nid) => - this.scene3d?.npcManager?.setSpeed(nid, payload?.speed)); - return; - } - if (cmd === 'npc.say') { - this._npcCmd(payload?.ref, (nid) => - this.scene3d?.npcManager?.say(nid, payload?.text, payload?.duration)); - return; - } - if (cmd === 'npc.damage') { - this._npcCmd(payload?.ref, (nid) => - this.scene3d?.npcManager?.damage(nid, payload?.amount)); - return; - } - if (cmd === 'npc.remove') { - this._npcCmd(payload?.ref, (nid) => - this.scene3d?.npcManager?.removeNpc(nid)); - return; - } - // === Constraints / связи объектов (Фаза 5) === - if (cmd === 'constraint.create') { - // payload: { kind: 'weld'|'hinge'|'spring', localRef, ... } - const cm = this.scene3d?.constraintManager; - if (cm && payload) { - let id = null; - if (payload.kind === 'weld') { - id = cm.addWeld(payload.refA, payload.refB); - } else if (payload.kind === 'hinge') { - id = cm.addHinge(payload.ref, { - pivotX: payload.pivotX, pivotZ: payload.pivotZ, - angle: payload.angle, - }); - } else if (payload.kind === 'spring') { - id = cm.addSpring(payload.ref, { - stiffness: payload.stiffness, damping: payload.damping, - }); - } - if (id == null) { - this._log('error', 'не удалось создать связь ' + payload.kind); - } else if (payload.localRef) { - // Маппинг localRef → реальный id (как у NPC). - if (!this._constraintLocalToReal) this._constraintLocalToReal = new Map(); - this._constraintLocalToReal.set(payload.localRef, id); - } - } - return; - } - if (cmd === 'constraint.hingeAngle') { - const cid = this._resolveConstraintId(payload?.ref); - if (cid != null) this.scene3d?.constraintManager?.setHingeAngle(cid, payload?.deg); - return; - } - if (cmd === 'constraint.springPush') { - const cid = this._resolveConstraintId(payload?.ref); - if (cid != null) { - this.scene3d?.constraintManager?.pushSpring( - cid, payload?.vx, payload?.vy, payload?.vz); - } - return; - } - if (cmd === 'constraint.remove') { - const cid = this._resolveConstraintId(payload?.ref); - if (cid != null) this.scene3d?.constraintManager?.remove(cid); - return; - } - // === Beam / Trail — лучи и следы (Фаза 5.2) === - if (cmd === 'fx.create') { - // payload: { kind: 'beam'|'trail', localRef, ... } - const bm = this.scene3d?.beamManager; - if (bm && payload) { - let id = null; - if (payload.kind === 'beam') { - id = bm.addBeam({ - from: payload.from, to: payload.to, - color: payload.color, width: payload.width, - // Задача 08: расширенные опции луча. - texture: payload.texture, customTextureUrl: payload.customTextureUrl, - textureMode: payload.textureMode, textureSpeed: payload.textureSpeed, - textureScale: payload.textureScale, - strokeColor: payload.strokeColor, strokeWidth: payload.strokeWidth, - colorSequence: payload.colorSequence, - transparencySequence: payload.transparencySequence, - widthSequence: payload.widthSequence, - faceMode: payload.faceMode, segments: payload.segments, - curved: payload.curved, curveHeight: payload.curveHeight, - attachOffset: payload.attachOffset, ignoreDepth: payload.ignoreDepth, - }); - } else if (payload.kind === 'trail') { - id = bm.addTrail(payload.ref, { - color: payload.color, width: payload.width, - lifetime: payload.lifetime, - }); - } - if (id == null) { - this._log('error', 'не удалось создать ' + payload.kind); - } else if (payload.localRef) { - if (!this._fxLocalToReal) this._fxLocalToReal = new Map(); - this._fxLocalToReal.set(payload.localRef, id); - } - } - return; - } - if (cmd === 'fx.beamColor') { - const fid = this._resolveFxId(payload?.ref); - if (fid != null) this.scene3d?.beamManager?.setBeamColor(fid, payload?.color); - return; - } - // === Задача 08: стрелка-указатель + расширенное управление лучом === - if (cmd === 'fx.createPointer') { - const bm = this.scene3d?.beamManager; - if (bm && payload) { - const id = bm.addPointer({ - from: payload.from, to: payload.to, preset: payload.preset, - color: payload.color, texture: payload.texture, - customTextureUrl: payload.customTextureUrl, - textureSpeed: payload.textureSpeed, width: payload.width, - strokeColor: payload.strokeColor, colorSequence: payload.colorSequence, - curved: payload.curved, curveHeight: payload.curveHeight, - faceMode: payload.faceMode, attachOffset: payload.attachOffset, - }); - if (id == null) { - this._log('error', 'не удалось создать стрелку-указатель'); - } else if (payload.localRef) { - if (!this._fxLocalToReal) this._fxLocalToReal = new Map(); - this._fxLocalToReal.set(payload.localRef, id); - } - } - return; - } - if (cmd === 'fx.pointerTarget') { - const fid = this._resolveFxId(payload?.ref); - if (fid != null) this.scene3d?.beamManager?.setPointerTarget(fid, payload?.to); - return; - } - if (cmd === 'fx.pointerUpdate') { - const fid = this._resolveFxId(payload?.ref); - const bm = this.scene3d?.beamManager; - if (fid != null && bm) { - const o = payload?.opts || {}; - if (o.preset) bm.applyPointerPreset(fid, o.preset); - bm.updateBeam(fid, o); - } - return; - } - if (cmd === 'fx.beamUpdate') { - const fid = this._resolveFxId(payload?.ref); - if (fid != null) this.scene3d?.beamManager?.updateBeam(fid, payload?.opts || {}); - return; - } - if (cmd === 'fx.beamVisible') { - const fid = this._resolveFxId(payload?.ref); - if (fid != null) this.scene3d?.beamManager?.setVisible(fid, payload?.visible !== false); - return; - } - if (cmd === 'fx.beamEndpoints') { - const fid = this._resolveFxId(payload?.ref); - if (fid != null) { - this.scene3d?.beamManager?.setBeamEndpoints( - fid, payload?.from, payload?.to); - } - return; - } - if (cmd === 'fx.remove') { - const fid = this._resolveFxId(payload?.ref); - if (fid != null) this.scene3d?.beamManager?.remove(fid); - return; - } - // === Звук — game.sound.* (Фаза 5.5) === - // Пользовательский звук из библиотеки проекта (Фаза 5.5). - // Встроенные пресеты ({name} без soundId) обрабатывает старый - // обработчик ниже — здесь только {soundId}. - if (cmd === 'sound.play' && payload && typeof payload.soundId === 'string') { - const sm = this.scene3d?.soundManager; - if (sm && this.scene3d?.soundLibrary?.count() > 0) { - // attachRef может быть локальным ref от scene.spawn — резолвим. - let attachRef = payload.attachRef; - if (typeof attachRef === 'string' && attachRef !== 'player' - && this._localToReal?.has(attachRef)) { - attachRef = this._localToReal.get(attachRef); - } - const instId = sm.play(payload.soundId, { - volume: payload.volume, - loop: payload.loop, - at: payload.at, - attachRef, - }); - if (instId != null && payload.localRef) { - if (!this._soundLocalToReal) this._soundLocalToReal = new Map(); - this._soundLocalToReal.set(payload.localRef, instId); - } - } - return; - } - if (cmd === 'sound.stop') { - const ref = payload?.ref; - if (ref != null && this.scene3d?.soundManager) { - const instId = this._soundLocalToReal?.has(ref) - ? this._soundLocalToReal.get(ref) : Number(ref); - if (Number.isFinite(instId)) { - this.scene3d.soundManager.stopSound(instId); - } - } - return; - } - // === Tool / инвентарь API (Фаза 4.2) === - if (cmd === 'inventory.give') { - // payload: { kind, modelTypeId, name, params } - const inv = this.scene3d?.inventory; - if (inv && payload) { - const idx = inv.add({ - kind: payload.kind || 'item', - modelTypeId: payload.modelTypeId || null, - name: payload.name || 'Предмет', - params: payload.params || {}, - }); - if (idx < 0) { - this._log('error', 'инвентарь полон — предмет не добавлен'); - } else if (payload.equip) { - // Сразу сделать активным и снарядить (для giveTool). - inv.setActive(idx); - const item = inv.slots[idx]; - if (item && item.kind === 'weapon' && this.scene3d?.weapons) { - try { this.scene3d.weapons.equip(item); } catch (e) {} - } - } - } - return; - } - if (cmd === 'inventory.remove') { - // payload: { modelTypeId? , name? } — убрать первый совпавший слот. - const inv = this.scene3d?.inventory; - if (inv && payload) { - const slots = inv.slots; - for (let i = 0; i < slots.length; i++) { - const s = slots[i]; - if (!s) continue; - const matchModel = payload.modelTypeId && s.modelTypeId === payload.modelTypeId; - const matchName = payload.name && s.name === payload.name; - if (matchModel || matchName) { - // Если убираем активное оружие — снять модель из руки. - if (i === inv.activeIndex && this.scene3d?.weapons) { - try { this.scene3d.weapons.unequip(); } catch (e) {} - } - inv.removeSlot(i); - break; - } - } - } - return; - } - if (cmd === 'inventory.clear') { - const inv = this.scene3d?.inventory; - if (inv) { - try { this.scene3d?.weapons?.unequip(); } catch (e) {} - inv.clear(); - } - return; - } - // === Мультиплеер-API: общее состояние комнаты (Фаза 4.3) === - if (cmd === 'room.set') { - // payload: { key, value } - if (payload && typeof payload.key === 'string') { - if (!this._roomState) this._roomState = {}; - const changed = this._roomState[payload.key] !== payload.value; - this._roomState[payload.key] = payload.value; - // Если есть Colyseus-комната — отправляем серверу (он - // обновит общее state; серверная схема — отдельная задача). - try { - this.scene3d?._mpSync?.room?.send?.('scriptRoomSet', { - key: payload.key, value: payload.value, - }); - } catch (e) { /* ignore */ } - // Локально сразу рассылаем событие изменения всем скриптам. - if (changed) { - this.routeGlobalEvent('roomChange', { - key: payload.key, value: payload.value, - }); - } - } - return; - } - if (cmd === 'mp.sendTo') { - // payload: { sessionId, name, data } — адресное сообщение игроку. - if (payload) { - const mp = this.scene3d?._mpSync; - if (mp && mp.room && typeof mp.room.send === 'function') { - // С комнатой — через сервер (релей по sessionId). - try { - mp.room.send('scriptMessage', { - to: payload.sessionId, - name: payload.name, - data: payload.data, - }); - } catch (e) { /* ignore */ } - } else if (payload.sessionId === 'local') { - // Single-player: сообщение «себе» — доставляем сразу. - this.routeGlobalEvent('mpMessage', { - from: 'local', name: payload.name, data: payload.data, - }); - } - } - return; - } - // === Команды / Teams (Фаза 4.4) === - if (cmd === 'teams.create') { - // payload: { name, color } - if (payload && typeof payload.name === 'string' && payload.name) { - if (!this._teams) this._teams = new Map(); - this._teams.set(payload.name, { - name: payload.name, - color: typeof payload.color === 'string' ? payload.color : '#888888', - }); - } - return; - } - if (cmd === 'teams.remove') { - if (payload && this._teams) { - this._teams.delete(payload.name); - // Если игрок был в этой команде — сбрасываем. - if (this._localPlayerTeam === payload.name) { - this._localPlayerTeam = null; - } - } - return; - } - if (cmd === 'player.setTeam') { - // payload: { team } — null/'' убирает команду. - const t = payload?.team; - let applied = null; - if (t == null || t === '') { - this._localPlayerTeam = null; - applied = ''; - } else if (typeof t === 'string') { - // Назначаем только если команда существует. - if (this._teams?.has(t)) { - this._localPlayerTeam = t; - applied = t; - } else { - this._log('error', 'команда не создана: ' + t); - } - } - // С Colyseus-комнатой — синхронизируем команду на сервер, - // чтобы остальные игроки видели её в Player.team. - if (applied != null) { - try { - this.scene3d?._mpSync?.room?.send?.('setTeam', { team: applied }); - } catch (e) { /* ignore */ } - } - return; - } - if (cmd === 'player.setSpeed') { - const player = this.scene3d?.player; - if (player) { - const m = Number(payload?.mul); - if (Number.isFinite(m) && m > 0) player._speedMul = m; - } - return; - } - // Подфаза 3.11 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md: - // game.player.equipAccessory(itemId) — надеть аксессуар прямо - // из скрипта игры (например выдать всем хеллоуинскую шапку при - // спавне). itemId — числовой id из rublox_items. - // Бэк фильтрует только published — на сервере ничего не настроишь. - if (cmd === 'player.equipAccessory') { - const player = this.scene3d?.player; - const itemId = Number(payload?.itemId); - if (!player || !Number.isFinite(itemId) || itemId <= 0) return; - (async () => { - try { - // Грузим item через публичный catalog (только published) - const resp = await fetch(`/api-storys/rublox/catalog/${itemId}`); - if (!resp.ok) return; - const item = await resp.json(); - if (item && typeof player.equipAccessory === 'function') { - await player.equipAccessory(item); - } - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] equipAccessory failed', e); - } - })(); - return; - } - if (cmd === 'player.unequipSlot') { - const player = this.scene3d?.player; - const slot = String(payload?.slot || ''); - if (player && slot && typeof player.unequipSlot === 'function') { - player.unequipSlot(slot); - } - return; - } - if (cmd === 'player.unequipAll') { - const player = this.scene3d?.player; - if (player && typeof player.unequipAll === 'function') { - player.unequipAll(); - } - return; - } - if (cmd === 'player.setJumpPower') { - const player = this.scene3d?.player; - if (player) { - const m = Number(payload?.mul); - if (Number.isFinite(m) && m > 0) player._jumpPowerMul = m; - } - return; - } - if (cmd === 'player.setGravityMul') { - // Множитель гравитации (для GD-стиля нужно ~1.23 — поднимает 22 до 27). - // Не зависит от gravityDir — работает в обоих направлениях. - const player = this.scene3d?.player; - if (player) { - const m = Number(payload?.mul); - if (Number.isFinite(m) && m > 0) player._gravityMul = m; - } - return; - } - if (cmd === 'player.setShipMode') { - // GD-гейммод Ship: тап-удержание = подъём (вертолёт-стиль). - const player = this.scene3d?.player; - if (player) player._shipMode = !!payload?.enabled; - return; - } - if (cmd === 'player.setUfoMode') { - // GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе. - const player = this.scene3d?.player; - if (player) player._ufoMode = !!payload?.enabled; - return; - } - if (cmd === 'player.setWaveMode') { - // GD-гейммод Wave: движение под ±45° (Space зажат — вверх, отпущен — вниз). - const player = this.scene3d?.player; - if (player) player._waveMode = !!payload?.enabled; - return; - } - if (cmd === 'player.setVy') { - // Прямое задание vy (для трамплинов, jump orb, boost-зон). - const player = this.scene3d?.player; - if (player) { - const v = Number(payload?.vy); - if (Number.isFinite(v)) player._vy = v; - } - return; - } - if (cmd === 'player.setRobotMode') { - // GD-гейммод Robot: variable-jump (высота = длительности удержания Space). - const player = this.scene3d?.player; - if (player) { - player._robotMode = !!payload?.enabled; - if (!player._robotMode) player._robotBoostLeft = 0; - } - return; - } - if (cmd === 'player.setDoubleJump') { - const player = this.scene3d?.player; - if (player) player._doubleJumpEnabled = !!payload?.enabled; - return; - } - if (cmd === 'player.playAnimation') { - const player = this.scene3d?.player; - if (player && typeof player.playEmote === 'function') { - const ok = player.playEmote(payload?.name); - if (!ok) { - this._log('error', 'playAnimation: эмоция не найдена — ' - + payload?.name + ' (доступно: wave, dance, cheer, sit)'); - } - } - return; - } - if (cmd === 'player.stopAnimation') { - const player = this.scene3d?.player; - if (player && typeof player.stopEmote === 'function') player.stopEmote(); - return; - } - if (cmd === 'player.setIceFriction') { - const player = this.scene3d?.player; - if (player) { - const v = Number(payload?.value); - if (Number.isFinite(v)) { - player._iceFriction = Math.max(0, Math.min(1, v)); - } - } - return; - } - if (cmd === 'player.setAutoRun') { - const player = this.scene3d?.player; - if (player) { - const s = Number(payload?.speed); - if (Number.isFinite(s)) player._autoRunSpeed = Math.max(0, s); - } - return; - } - if (cmd === 'player.boostJump') { - const player = this.scene3d?.player; - if (player) { - const s = Number(payload?.strength); - if (Number.isFinite(s) && s > 0) { - // boostJump учитывает текущую гравитацию: при flipped — толкает к потолку (vy<0) - const gDir = player._gravityDir || 1; - const base = player.JUMP_VELOCITY * (player._jumpPowerMul || 1); - player._vy = base * s * gDir; - } - } - return; - } - if (cmd === 'player.flipGravity') { - // Меняет направление гравитации (как blue orb в GD): +1 ↔ -1 - const player = this.scene3d?.player; - if (player) { - player._gravityDir = (player._gravityDir || 1) > 0 ? -1 : 1; - // Сбрасываем "second jump used" чтобы после флипа доступен прыжок - player._doubleJumpUsed = false; - } - return; - } - if (cmd === 'player.setGravityDir') { - // Явно задать направление: dir=1 (вниз) или -1 (вверх). - const player = this.scene3d?.player; - if (player) { - const d = Number(payload?.dir); - if (d === 1 || d === -1) { - player._gravityDir = d; - player._doubleJumpUsed = false; - } - } - return; - } - if (cmd === 'player.getGravityDir') { - // Возвращает текущее значение через broadcast-style "reply" - // Скрипту это нужно через геттер game.player.gravityDir — см. shim в Worker - return; - } - // === HUD / Input / App === - if (cmd === 'hud.setVisible') { - try { - const v = !!payload?.visible; - this.scene3d?._setStdHudVisible?.(v); - } catch (e) {} - return; - } - if (cmd === 'hud.setHotbarVisible') { - try { this.scene3d?._setHotbarVisible?.(!!payload?.visible); } catch (e) {} - return; - } - if (cmd === 'hud.setHpVisible') { - try { this.scene3d?._setHpVisible?.(!!payload?.visible); } catch (e) {} - return; - } - if (cmd === 'input.setCursorMode') { - try { - const mode = payload?.mode === 'ui' ? 'ui' : 'game'; - const player = this.scene3d?.player; - if (player?.setUiCursorMode) { - player.setUiCursorMode(mode === 'ui'); - if (mode === 'ui') { - try { document.exitPointerLock?.(); } catch (e) {} - // Подписываемся на mouse-события и транслируем в Worker. - if (player.setUiMouseMoveCallback) { - let lastMM = 0; - player.setUiMouseMoveCallback((x, y) => { - const now = performance.now(); - if (now - lastMM < 20) return; - lastMM = now; - this.routeGlobalEvent('mouseMove', { x, y }); - }); - } - if (player.setUiMouseDownCallback) { - player.setUiMouseDownCallback((x, y) => { - this.routeGlobalEvent('mouseDown', { x, y }); - }); - } - if (player.setUiMouseUpCallback) { - player.setUiMouseUpCallback((x, y) => { - this.routeGlobalEvent('mouseUp', { x, y }); - }); - } - } else if (player._requestPointerLockSafe) { - // Отписываемся при возврате в game-режим - if (player.setUiMouseMoveCallback) { - player.setUiMouseMoveCallback(null); - } - if (player.setUiMouseDownCallback) { - player.setUiMouseDownCallback(null); - } - if (player.setUiMouseUpCallback) { - player.setUiMouseUpCallback(null); - } - try { player._requestPointerLockSafe(); } catch (e) {} - } - // Сообщить редактору/плееру чтобы синхронизировать UI-state - try { this.scene3d?._onCursorModeChange?.(mode); } catch (e) {} - } - } catch (e) {} - return; - } - if (cmd === 'app.exit') { - try { - // На Майнкрафтия-плеере это шло на свой роут /kubikon3d - // (лента игр). В выделенном плеере (player.rublox.pro) - // таких роутов нет — переходим на ленту Рублокса. - window.location.assign(this._resolveExternalUrl('/kubikon3d')); - } catch (e) {} - return; - } - if (cmd === 'app.navigate') { - try { - const url = String(payload?.url || ''); - if (url) window.location.assign(this._resolveExternalUrl(url)); - } catch (e) {} - return; - } - // === Универсальное хранилище сейвов (game.save.*) === - if (cmd === 'save.get') { - this._saveGet(scriptId, payload); - return; - } - if (cmd === 'save.getAll') { - this._saveGetAll(scriptId, payload); - return; - } - if (cmd === 'save.set') { - this._saveSet(payload); - return; - } - if (cmd === 'save.merge') { - this._saveMerge(payload); - return; - } - if (cmd === 'save.leaderboard') { - this._saveLeaderboard(scriptId, payload); - return; - } - if (cmd === 'economy.reward') { - this._economyReward(scriptId, payload); - return; - } - if (cmd === 'economy.dailyCheck') { - this._economyDailyCheck(scriptId, payload); - return; - } - if (cmd === 'economy.getBalance') { - this._economyGetBalance(scriptId, payload); - return; - } - if (cmd === 'economy.spend') { - this._economySpend(scriptId, payload); - return; - } - if (cmd === 'camera.shake') { - const player = this.scene3d?.player; - if (player) { - const amp = Number(payload?.amp); - const dur = Number(payload?.dur); - if (Number.isFinite(amp) && Number.isFinite(dur) && amp > 0 && dur > 0) { - player._cameraShakeAmp = amp; - player._cameraShakeLeft = dur; - } - } - return; - } - // === Камера: FOV, привязка, катсцены (Фаза 5.7) === - if (cmd === 'camera.fov') { - this.scene3d?.player?.setCameraFov?.(payload?.degrees); - return; - } - if (cmd === 'camera.focus') { - // payload: { ref, distance, height } — следить за объектом. - const player = this.scene3d?.player; - if (player && payload && typeof payload.ref === 'string') { - const ref = payload.ref; - // getTarget резолвит позицию объекта каждый кадр. - const getTarget = () => { - const tgt = this._resolveTweenTarget(ref); - if (tgt && tgt.data) { - return { x: tgt.data.x, y: tgt.data.y, z: tgt.data.z }; - } - return null; - }; - player.cameraFocusOn(getTarget, { - distance: payload.distance, height: payload.height, - }); - } - return; - } - if (cmd === 'camera.cutscene') { - // payload: { points: [{x,y,z}], lookAt: [{x,y,z}], segDuration } - const player = this.scene3d?.player; - if (player && payload && Array.isArray(payload.points)) { - player.cameraCutscene( - payload.points, payload.lookAt, payload.segDuration, - // onDone — событие скрипту. - () => this.routeGlobalEvent('cutsceneDone', {}), - ); - } - return; - } - if (cmd === 'camera.reset') { - this.scene3d?.player?.cameraReset?.(); - return; - } - if (cmd === 'player.setSkinVisible') { - const player = this.scene3d?.player; - if (player) { - const v = !!payload?.visible; - player._skinVisibleScripted = v; - // Применяем сразу — но также флаг будет применяться каждый - // кадр в _tick (на случай если меши ещё не загружены сейчас). - if (Array.isArray(player._modelMeshes)) { - for (const m of player._modelMeshes) { - try { m.setEnabled(v); } catch (e) {} - } - } - } - return; - } - if (cmd === 'player.setCameraMode') { - const player = this.scene3d?.player; - if (player && typeof payload?.mode === 'string') { - const valid = ['first', 'third', 'front', 'sideview']; - if (valid.includes(payload.mode)) { - player._cameraMode = payload.mode; - try { player._applyCameraMode?.(); } catch (e) {} - } - } - return; - } - if (cmd === 'player.setCrouch') { - const player = this.scene3d?.player; - if (player) { - const want = !!payload?.enabled; - player._scriptForcedCrouch = want; - if (want !== player._crouching) { - player._crouching = want; - const newHalfH = want ? player.HALF_H_CROUCH : player.HALF_H_NORMAL; - // КРИТИЧНО: _pos — центр капсулы. При смене HALF_H - // центр надо сдвинуть на ту же дельту, иначе «низ ног» - // (_pos.y - HALF_H) меняется и персонажа подкидывает - // вверх при приседе. Сдвигаем — низ ног остаётся на месте. - const dH = newHalfH - player.HALF_H; - player.HALF_H = newHalfH; - if (player._pos) player._pos.y += dH; - } - } - return; - } - if (cmd === 'player.setFacing') { - // Развернуть модель игрока на угол yaw (радианы). Полезно - // в кат-сценах, когда игрок стоит лицом куда нужно. - const player = this.scene3d?.player; - if (player) { - const yaw = Number(payload?.yaw); - if (Number.isFinite(yaw)) { - player._modelYaw = yaw; - if (player._modelRoot) player._modelRoot.rotation.y = yaw; - } - } - return; - } - if (cmd === 'player.emote') { - // Проиграть эмоцию персонажа (wave/dance/cheer/sit/paint). - // Работает только для R15-скинов. - const player = this.scene3d?.player; - if (player && typeof player.playEmote === 'function') { - const name = payload?.name; - if (typeof name === 'string') { - try { player.playEmote(name); } catch (e) { /* ignore */ } - } - } - return; - } - if (cmd === 'player.stopEmote') { - const player = this.scene3d?.player; - if (player && typeof player.stopEmote === 'function') { - try { player.stopEmote(); } catch (e) { /* ignore */ } - } - return; - } - if (cmd === 'timer.start' || cmd === 'timer.stop' || cmd === 'timer.submit') { - // Делегируем в scene3d — у него есть колбэки для UI/API - const fn = this.scene3d?.[cmd === 'timer.start' ? '_timerStart' - : cmd === 'timer.stop' ? '_timerStop' : '_timerSubmit']; - if (typeof fn === 'function') { - try { fn.call(this.scene3d); } catch (e) { /* ignore */ } - } - return; - } - if (cmd === 'self.move') { - this._applySelfMove(payload); - return; - } - if (cmd === 'scene.rotate') { - try { - const ry = Number(payload?.rotationY); - if (!Number.isFinite(ry)) return; - const pm = this.scene3d?.primitiveManager; - if (!pm) return; - const rid = this._resolvePrimitiveId(payload?.id); - const data = rid != null ? pm.instances.get(rid) : null; - if (data) { - data.rotationY = ry; - if (data.mesh?.rotation) { - data.mesh.rotation.y = ry; - if (data._worldMatrixFrozen) { - try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} - data._worldMatrixFrozen = false; - } - } - } - // snapshot не шлём — объект всё ещё findOne'ится по тому же ref'у, - // только rotationY обновился, для скрипта это прозрачно. - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] scene.rotate failed', e); - } - return; - } - if (cmd === 'scene.setRotation') { - try { - const rx = Number(payload?.rx); - const ry = Number(payload?.ry); - const rz = Number(payload?.rz); - if (!Number.isFinite(rx) || !Number.isFinite(ry) || !Number.isFinite(rz)) return; - const pm = this.scene3d?.primitiveManager; - if (!pm) return; - const rid = this._resolvePrimitiveId(payload?.id); - const data = rid != null ? pm.instances.get(rid) : null; - if (data) { - data.rotationX = rx; - data.rotationY = ry; - data.rotationZ = rz; - if (data.mesh?.rotation) { - data.mesh.rotation.x = rx; - data.mesh.rotation.y = ry; - data.mesh.rotation.z = rz; - if (data._worldMatrixFrozen) { - try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} - data._worldMatrixFrozen = false; - } - } - } - } catch (e) { - console.warn('[GameRuntime] scene.setRotation failed', e); - } - return; - } - if (cmd === 'scene.setCollide') { - try { - const canCollide = !!payload?.canCollide; - const pm = this.scene3d?.primitiveManager; - if (!pm) return; - const rid = this._resolvePrimitiveId(payload?.id); - const data = rid != null ? pm.instances.get(rid) : null; - if (data) { - data.canCollide = canCollide; - if (data.mesh?.metadata) data.mesh.metadata.canCollide = canCollide; - this.scene3d?.physics?.setSpatialDirty?.(); - } - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] scene.setCollide failed', e); - } - return; - } - if (cmd === 'scene.setColor') { - try { - const color = payload?.color; - if (typeof color !== 'string') return; - // Окрашиваемый блок (studs-block): ref 'block:x,y,z' → BlockManager. - const ref = payload?.id; - if (typeof ref === 'string' && ref.startsWith('block:')) { - const parts = ref.slice(6).split(',').map(Number); - if (parts.length === 3 && parts.every(Number.isFinite)) { - this.scene3d?.blockManager?.setBlockColor?.(parts[0], parts[1], parts[2], color); - } - return; - } - const pm = this.scene3d?.primitiveManager; - if (!pm) return; - const rid = this._resolvePrimitiveId(payload?.id); - const data = rid != null ? pm.instances.get(rid) : null; - if (data) { - data.color = color; - if (data.mesh?.material) { - const c = Color3.FromHexString(color); - data.mesh.material.diffuseColor = c; - // Если материал neon — обновляем emissive тоже - if (data.material === 'neon') { - data.mesh.material.emissiveColor = c; - } - if (data.material === 'studs') { - data.mesh.material.emissiveColor = new Color3(c.r * 0.45, c.g * 0.45, c.b * 0.45); - } - } - } - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] scene.setColor failed', e); - } - return; - } - if (cmd === 'scene.setOpacity') { - try { - const id = this._resolvePrimitiveId(payload?.ref); - const pm = this.scene3d?.primitiveManager; - if (id != null && pm) pm.updateInstance(id, { opacity: payload.opacity }); - } catch (e) { - console.warn('[GameRuntime] scene.setOpacity failed', e); - } - return; - } - if (cmd === 'scene.setScale') { - try { - const id = this._resolvePrimitiveId(payload?.ref); - const pm = this.scene3d?.primitiveManager; - if (id != null && pm) { - pm.updateInstance(id, { sx: payload.sx, sy: payload.sy, sz: payload.sz }); - } - } catch (e) { - console.warn('[GameRuntime] scene.setScale failed', e); - } - return; - } - if (cmd === 'scene.setMaterial') { - try { - const id = this._resolvePrimitiveId(payload?.ref); - const pm = this.scene3d?.primitiveManager; - if (id != null && pm) pm.updateInstance(id, { material: payload.material }); - } catch (e) { - console.warn('[GameRuntime] scene.setMaterial failed', e); - } - return; - } - if (cmd === 'scene.clone') { - try { - const id = this._resolvePrimitiveId(payload?.ref); - const pm = this.scene3d?.primitiveManager; - if (id == null || !pm) return; - const src = pm.instances.get(id); - if (!src) return; - const newId = pm.addInstance(src.type, { - x: (src.x || 0) + (Number(payload.dx) || 0), - y: (src.y || 0) + (Number(payload.dy) || 0), - z: (src.z || 0) + (Number(payload.dz) || 0), - sx: src.sx, sy: src.sy, sz: src.sz, - color: src.color, material: src.material, - rotationY: src.rotationY, - }); - if (newId != null) { - if (!this._localToReal) this._localToReal = new Map(); - this._localToReal.set(payload.newRef, 'primitive:' + newId); - this.scheduleSceneSnapshot(); - } - } catch (e) { - console.warn('[GameRuntime] scene.clone failed', e); - } - return; - } - if (cmd === 'self.registerInteract') { - try { - const t = payload?.target; - if (!t) return; - // ref объекта-носителя скрипта - const ref = (t.kind && (t.ref ?? t.id) != null) - ? (t.kind + ':' + (t.ref ?? t.id)) : null; - if (!ref) return; - // не дублируем — один объект = одна запись - if (!this._interactables.some(it => it.ref === ref)) { - this._interactables.push({ - ref, - target: t, - text: payload.text || 'Взаимодействовать', - distance: Number(payload.distance) || 4, - key: payload.key || 'e', - }); - } - } catch (e) { - console.warn('[GameRuntime] self.registerInteract failed', e); - } - return; - } - if (cmd === 'scene.setLabel') { - try { - const ref = payload?.ref; - const text = payload?.text; - if (typeof ref !== 'string') return; - // ленивое создание менеджера меток - if (!this.scene3d._labelManager) { - const { LabelManager } = require('./LabelManager'); - this.scene3d._labelManager = new LabelManager(this.scene3d.scene); - } - const lm = this.scene3d._labelManager; - // резолвим меш объекта (примитив или модель) - const tgt = this._resolveTweenTarget(ref); - const mesh = tgt && (tgt.data.mesh || tgt.data.rootMesh || tgt.data.rootNode); - if (mesh) { - lm.setLabel(ref, mesh, text, payload?.opts || {}); - } - } catch (e) { - console.warn('[GameRuntime] scene.setLabel failed', e); - } - return; - } - if (cmd === 'scene.clearLabel') { - try { - const lm = this.scene3d?._labelManager; - if (lm && typeof payload?.ref === 'string') lm.clearLabel(payload.ref); - } catch (e) { - console.warn('[GameRuntime] scene.clearLabel failed', e); - } - return; - } - if (cmd === 'scene.setData') { - try { - const { ref, key, value } = payload || {}; - if (typeof ref !== 'string' || typeof key !== 'string') return; - if (!this._objectData[ref]) this._objectData[ref] = {}; - this._objectData[ref][key] = value; - this.scheduleDataSnapshot(); - } catch (e) { - console.warn('[GameRuntime] scene.setData failed', e); - } - return; - } - // === Теги объектов (Фаза 5.6) — game.scene.tag/untag/getTagged === - // Теги хранятся как массив в _objectData[ref].__tags — переиспользуем - // готовый канал dataSnapshot, отдельная синхронизация не нужна. - if (cmd === 'scene.tag' || cmd === 'scene.untag') { - try { - const { ref, tag } = payload || {}; - if (typeof ref !== 'string' || typeof tag !== 'string' || !tag) return; - if (!this._objectData[ref]) this._objectData[ref] = {}; - const cur = Array.isArray(this._objectData[ref].__tags) - ? this._objectData[ref].__tags : []; - this._objectData[ref].__tags = cmd === 'scene.tag' - ? (cur.includes(tag) ? cur : [...cur, tag]) - : cur.filter(t => t !== tag); - this.scheduleDataSnapshot(); - } catch (e) { - console.warn('[GameRuntime] scene.tag failed', e); - } - return; - } - // === Collision groups (Фаза 5.9) — проходимость объекта/группы === - // physics.passThrough — игрок проходит сквозь объект (объект виден). - // target: ref одного объекта ИЛИ тег (тогда применяется ко всей - // группе объектов с этим тегом — теги = collision groups). - if (cmd === 'physics.passThrough') { - try { - const { target, on } = payload || {}; - if (typeof target !== 'string' || !target) return; - const pm = this.scene3d?.primitiveManager; - if (!pm) return; - // canCollide = !on (passThrough=true → коллизия выключена). - const canCollide = !on; - // Собираем список ref: либо один объект, либо все с тегом. - let refs; - if (target.indexOf(':') >= 0) { - refs = [target]; // похоже на ref объекта - } else { - // Тег — все объекты с ним. - refs = []; - for (const r of Object.keys(this._objectData)) { - const bag = this._objectData[r]; - if (bag && Array.isArray(bag.__tags) && bag.__tags.includes(target)) { - refs.push(r); - } - } - } - for (const r of refs) { - const rid = this._resolvePrimitiveId(r); - if (rid != null) pm.updateInstance(rid, { canCollide }); - } - // Сбрасываем кэш spatial-grid физики — иначе grid до 50мс - // держит старое состояние, и при возврате твёрдости (on=false) - // UNSTUCK не видит стену, игрок застревает в ней. - this.scene3d?.physics?.invalidateSpatialGrid?.(); - } catch (e) { - console.warn('[GameRuntime] physics.passThrough failed', e); - } - return; - } - if (cmd === 'physics.setVelocity' || cmd === 'physics.applyImpulse') { - try { - const id = this._resolvePrimitiveId(payload?.ref); - const pm = this.scene3d?.primitiveManager; - const dm = this.scene3d?.dynamics; - if (id == null || !pm || !dm) return; - const data = pm.instances.get(id); - if (!data) return; - const isImpulse = cmd === 'physics.applyImpulse'; - const vx = isImpulse ? payload.ix : payload.vx; - const vy = isImpulse ? payload.iy : payload.vy; - const vz = isImpulse ? payload.iz : payload.vz; - const ok = dm.applyToInstance(data, vx, vy, vz, isImpulse ? 'impulse' : 'set'); - if (!ok) { - this._log('error', cmd + ': объект закреплён (anchored) — ' - + 'физика работает только для незакреплённых объектов'); - } - } catch (e) { - console.warn('[GameRuntime] ' + cmd + ' failed', e); - } - return; - } - if (cmd === 'physics.explode') { - try { - const { x, y, z, radius, damage, force } = payload || {}; - const r = Number(radius) || 3; - // визуальный эффект взрыва - this._handleCommand(scriptId, 'scene.particles', { - type: 'explosion', position: { x, y, z }, - duration: 1.2, count: 2, color: null, - }); - // урон игроку если в радиусе - const player = this.scene3d?.player; - if (player && Number(damage) > 0) { - const pp = player._pos || player.position; - if (pp) { - const dx = pp.x - x, dy = (pp.y || 0) - y, dz = pp.z - z; - if (dx*dx + dy*dy + dz*dz <= r*r) { - try { player.takeDamage(Number(damage), 'explosion'); } catch (e) {} - } - } - } - // убиваем мобов в радиусе - const zm = this.scene3d?.zombieManager; - if (zm && typeof zm.getMobsSnapshot === 'function') { - const mobs = zm.getMobsSnapshot(); - for (const m of mobs) { - const dx = m.x - x, dy = (m.y || 0) - y, dz = m.z - z; - if (dx*dx + dy*dy + dz*dz <= r*r) { - try { zm.killById(m.id); } catch (e) {} - } - } - } - } catch (e) { - console.warn('[GameRuntime] physics.explode failed', e); - } - return; - } - if (cmd === 'tween.start') { - this._startTween(scriptId, payload); - return; - } - if (cmd === 'tween.cancel') { - const tid = payload?.tweenId; - if (tid != null) { - const i = this._tweens.findIndex(t => t.tweenId === tid && t.scriptId === scriptId); - if (i >= 0) this._tweens.splice(i, 1); - } - return; - } - if (cmd === 'scene.setTexture') { - // Установить динамическую текстуру примитива из dataURL. - // Используется GD-скинами куба (canvas-фабрика в скрипте → dataURL → текстура). - try { - const dataUrl = payload?.dataUrl; - if (typeof dataUrl !== 'string') return; - const pm = this.scene3d?.primitiveManager; - if (!pm) return; - const rid = this._resolvePrimitiveId(payload?.id); - if (rid != null) pm.setTexture(rid, dataUrl); - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] scene.setTexture failed', e); - } - return; - } - // === AUDIO: GD-музыка и SFX === - if (cmd === 'audio.playSfx') { - try { - const am = this.scene3d?.gameAudioManager; - if (am && payload?.name) am.playSfx(payload.name); - } catch (e) { - console.warn('[GameRuntime] audio.playSfx failed', e); - } - return; - } - if (cmd === 'audio.playMusic') { - try { - const am = this.scene3d?.gameAudioManager; - if (am && payload?.trackId) am.playMusic(payload.trackId); - } catch (e) { - console.warn('[GameRuntime] audio.playMusic failed', e); - } - return; - } - if (cmd === 'audio.stopMusic') { - try { - const am = this.scene3d?.gameAudioManager; - if (am) am.stopMusic(); - } catch (e) { - console.warn('[GameRuntime] audio.stopMusic failed', e); - } - return; - } - if (cmd === 'audio.setMuted') { - try { - const am = this.scene3d?.gameAudioManager; - if (am) am.setMuted(!!payload?.muted); - } catch (e) { - console.warn('[GameRuntime] audio.setMuted failed', e); - } - return; - } - if (cmd === 'scene.setVisible') { - try { - const kind = payload?.kind; - const id = payload?.id; - const visible = !!payload?.visible; - if (id == null) return; - if (kind === 'primitive') { - const pm = this.scene3d?.primitiveManager; - if (!pm) return; - const rid = this._resolvePrimitiveId(id); - const data = rid != null ? pm.instances.get(rid) : null; - if (data) { - data.visible = visible; - if (data.mesh) data.mesh.setEnabled(visible); - } - } else if (kind === 'model') { - const mm = this.scene3d?.modelManager; - if (!mm) return; - let data = mm.instances.get(id); - if (!data && typeof id === 'string') { - const n = Number(id); - if (Number.isFinite(n)) data = mm.instances.get(n); - } - if (data) { - data.visible = visible; - if (data.rootMesh) data.rootMesh.setEnabled(visible); - } - } - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] scene.setVisible failed', e); - } - return; - } - if (cmd === 'scene.setFolderYaw') { - try { - const fm = this.scene3d?.folderManager; - if (!fm) return; - const name = payload?.folderName; - const angle = Number(payload?.angle); - const pivot = payload?.pivot; - if (typeof name !== 'string' || !Number.isFinite(angle) || !pivot) return; - const folder = fm.findByName(name); - if (!folder) return; - fm.setFolderYawY(folder.id, angle, pivot); - this.scheduleSceneSnapshot(); - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] scene.setFolderYaw failed', e); - } - return; - } - if (cmd === 'self.delete') { - this._applySelfDelete(payload); - return; - } - if (cmd === 'scene.spawn') { - this._applySceneSpawn(scriptId, payload); - return; - } - if (cmd === 'scene.delete') { - this._applySceneDelete(payload); - return; - } - if (cmd === 'ui.set' || cmd === 'ui.flash' || cmd === 'ui.clear') { - // Просто пробрасываем в onHud колбэк — UI на стороне React сам отрисует - if (this._onHud) { - try { this._onHud({ cmd, payload }); } catch (e) { /* ignore */ } - } - return; - } - if (cmd === 'sound.play') { - this._playSound(payload); - return; - } - if (cmd === 'scene.particles') { - this._spawnParticles(payload); - return; - } - if (cmd === 'mob.kill') { - try { - const id = Number(payload?.id); - if (Number.isFinite(id) && this.scene3d?.zombieManager) { - this.scene3d.zombieManager.killById(id); - } - } catch (e) { - this._log('error', 'mob.kill failed: ' + (e?.message || e)); - } - return; - } - if (cmd === 'gui.update') { - // payload: { id, patch } - try { - let id = payload?.id; - const patch = payload?.patch || {}; - if (typeof id !== 'string') return; - // Резолвим локальный ref (тот что вернул gui.create) → реальный id - if (this._guiLocalToReal?.has(id)) id = this._guiLocalToReal.get(id); - this.scene3d?.updateGuiElement?.(id, patch); - this.scheduleGuiSnapshot(); - } catch (e) { - this._log('error', 'gui.update failed: ' + (e?.message || e)); - } - return; - } - if (cmd === 'gui.create') { - try { - const type = payload?.type; - const opts = { ...(payload?.opts || {}) }; - const localRef = payload?.localRef; - if (typeof type !== 'string') return; - // Помечаем как созданный скриптом — чтобы НЕ попал в - // сериализацию проекта (иначе автосейв сохранит его в БД - // и после Stop он «вернётся» из сохранённого проекта). - opts._scriptCreated = true; - // Резолвим parentId если это локальный ref из предыдущего create - if (opts.parentId && this._guiLocalToReal?.has(opts.parentId)) { - opts.parentId = this._guiLocalToReal.get(opts.parentId); - } - const realId = this.scene3d?.createGuiElement?.(type, opts); - if (realId && localRef) { - if (!this._guiLocalToReal) this._guiLocalToReal = new Map(); - if (!this._guiRealToLocal) this._guiRealToLocal = new Map(); - this._guiLocalToReal.set(localRef, realId); - this._guiRealToLocal.set(realId, localRef); - } - this.scheduleGuiSnapshot(); - } catch (e) { - this._log('error', 'gui.create failed: ' + (e?.message || e)); - } - return; - } - if (cmd === 'gui.remove') { - try { - let id = payload?.id; - if (typeof id !== 'string') return; - const localId = id; - if (this._guiLocalToReal?.has(id)) id = this._guiLocalToReal.get(id); - this.scene3d?.removeGuiElement?.(id); - // Чистим mapping чтобы не утекало - if (this._guiLocalToReal?.has(localId)) this._guiLocalToReal.delete(localId); - this.scheduleGuiSnapshot(); - } catch (e) { - this._log('error', 'gui.remove failed: ' + (e?.message || e)); - } - return; - } - if (cmd === 'broadcast') { - // Рассылаем именованное сообщение всем sandbox'ам - this.routeGlobalEvent('message', { - name: String(payload?.name || ''), - data: payload?.data ?? null, - }); - return; - } - if (cmd === 'player.crosshair') { - const type = String(payload?.type || 'none').toLowerCase(); - try { this.scene3d?.setCrosshair?.(type); } catch (e) { /* ignore */ } - if (this._onCrosshair) { - try { this._onCrosshair(type); } catch (e) { /* ignore */ } - } - return; - } - // === Задача 07: скины игрока === - if (cmd === 'player.setSkin') { - const player = this.scene3d?.player; - const slug = payload?.slug; - if (player && typeof slug === 'string' && slug) { - const typeId = this._resolveSkinTypeId(slug); - // Помечаем доступным (setSkin неявно разблокирует). - this._ensureSkinState(); - this._skinState.unlocked.add(slug); - this._skinState.current = slug; - // Асинхронная перезагрузка модели; по завершении шлём skinChanged. - Promise.resolve(player.reloadSkin?.(typeId)).then(() => { - this.routeGlobalEvent?.('skinChanged', { slug }); - try { this.scene3d?._onPlayerSkinChanged?.(slug); } catch (e) {} - }).catch((e) => { - this._log('error', 'setSkin failed: ' + (e?.message || e)); - }); - } - return; - } - if (cmd === 'player.unlockSkin') { - const slug = payload?.slug; - if (typeof slug === 'string' && slug) { - this._ensureSkinState(); - this._skinState.unlocked.add(slug); - this.routeGlobalEvent?.('skinUnlocked', { slug }); - } - return; - } - if (cmd === 'player.openSkinShop') { - this._ensureSkinState(); - try { this.scene3d?._openSkinShop?.(); } catch (e) {} - return; - } - if (cmd === 'player.closeSkinShop') { - try { this.scene3d?._closeSkinShop?.(); } catch (e) {} - return; - } - if (cmd === 'player.setSkinCoins') { - this._ensureSkinState(); - const n = Number(payload?.amount); - if (Number.isFinite(n)) { - this._skinState.coins = Math.max(0, Math.floor(n)); - this._broadcastSkinsSnapshot(); - } - return; - } - // Покупка скина из встроенного магазина (намерение от React-оверлея - // или из скрипта). Списывает локальные рублики, разблокирует, надевает. - if (cmd === 'player.buySkin') { - this._ensureSkinState(); - const slug = payload?.slug; - const price = Number(payload?.price) || 0; - if (typeof slug !== 'string' || !slug) return; - const st = this._skinState; - const owned = st.unlocked.has(slug); - if (owned) { - // Уже куплен — просто надеть. - this._handleCommand(scriptId, 'player.setSkin', { slug }); - return; - } - if (st.coins < price) { - // Не хватает — сообщаем оверлею. - try { this.scene3d?._onSkinBuyResult?.({ slug, ok: false, reason: 'no_coins' }); } catch (e) {} - return; - } - st.coins -= price; - st.unlocked.add(slug); - this._handleCommand(scriptId, 'player.setSkin', { slug }); - this._broadcastSkinsSnapshot(); - try { this.scene3d?._onSkinBuyResult?.({ slug, ok: true }); } catch (e) {} - return; - } - // === Задача 02: setCameraZoom / setCameraZoomLimits / setShiftLock === - if (cmd === 'player.setCameraZoom') { - const player = this.scene3d?.player; - if (player && typeof player.setCameraZoom === 'function') { - try { player.setCameraZoom(payload?.distance); } catch (e) {} - } - return; - } - if (cmd === 'player.setCameraZoomLimits') { - const player = this.scene3d?.player; - if (player && typeof player.setCameraZoomLimits === 'function') { - try { player.setCameraZoomLimits(payload?.min, payload?.max); } catch (e) {} - } - return; - } - if (cmd === 'player.setShiftLock') { - const player = this.scene3d?.player; - if (player && typeof player.setShiftLock === 'function') { - try { player.setShiftLock(payload?.on); } catch (e) {} - } - return; - } - // === Задача 02: environment API === - if (cmd === 'environment.setSkyColor') { - try { - const hex = String(payload?.color || ''); - const scene = this.scene3d?.scene; - if (scene && hex) { - // Парсим #rrggbb → clearColor - const m = hex.match(/^#?([0-9a-f]{6})$/i); - if (m) { - const n = parseInt(m[1], 16); - const r = ((n >> 16) & 0xff) / 255; - const g = ((n >> 8) & 0xff) / 255; - const b = (n & 0xff) / 255; - if (scene.clearColor) { - scene.clearColor.r = r; - scene.clearColor.g = g; - scene.clearColor.b = b; - scene.clearColor.a = 1; - } - } - } - } catch (e) { - this._log('error', 'environment.setSkyColor failed: ' + (e?.message || e)); - } - return; - } - if (cmd === 'environment.setFog') { - try { - const env = this.scene3d?.environment; - if (env && typeof env.setFog === 'function') { - env.setFog(payload?.enabled, payload?.color, payload?.density); - } - } catch (e) {} - return; - } - if (cmd === 'environment.setTimeOfDay') { - try { - const env = this.scene3d?.environment; - if (env && typeof env.setTimeOfDay === 'function') { - env.setTimeOfDay(payload?.hours); - } - } catch (e) {} - return; - } - // === Задача 03: GUI tween === - if (cmd === 'gui.tween') { - try { - const guiId = payload?.id; - if (typeof guiId !== 'string' || !guiId) return; - const gm = this.scene3d?.guiManager; - if (!gm) return; - // Резолв localRef → realId если есть - let realId = guiId; - if (this._guiLocalToReal?.has(guiId)) realId = this._guiLocalToReal.get(guiId); - const el = gm.elements?.find(e => e.id === realId); - if (!el) return; - if (!this._guiTweens) this._guiTweens = []; - // Снимок начальных значений по тем ключам что есть в props - const props = payload.props || {}; - const propKeys = Object.keys(props); - // Перебивка: убрать существующие tween'ы на ТОТ ЖЕ id, - // которые анимируют ХОТЯ БЫ ОДИН из этих же ключей. - // Это поведение Roblox TweenService: новый tween на тот же ключ перебивает старый. - for (let j = this._guiTweens.length - 1; j >= 0; j--) { - const old = this._guiTweens[j]; - if (old.realId !== realId) continue; - const oldKeys = Object.keys(old.target); - const overlap = oldKeys.some(k => propKeys.includes(k)); - if (overlap) this._guiTweens.splice(j, 1); - } - const start = {}; - for (const k of propKeys) { - if (k in el) start[k] = el[k]; - else if (k === 'scaleX' || k === 'scaleY' || k === 'rotation') start[k] = el[k] || (k === 'rotation' ? 0 : 1); - } - this._guiTweens.push({ - tweenId: payload.tweenId, - scriptId, - realId, - start, target: { ...props }, - elapsed: 0, - duration: Math.max(0.001, Number(payload.duration) || 0.5), - delay: Math.max(0, Number(payload.delay) || 0), - easing: payload.easing || 'ease', - repeat: Number.isFinite(payload.repeat) ? payload.repeat : 0, - reverses: !!payload.reverses, - iter: 0, - dir: 1, // 1 = вперёд, -1 = обратно (для reverses) - }); - } catch (e) { - this._log('error', 'gui.tween failed: ' + (e?.message || e)); - } - return; - } - if (cmd === 'gui.cancelTween') { - const tid = payload?.tweenId; - if (tid != null && this._guiTweens) { - const i = this._guiTweens.findIndex(t => t.tweenId === tid); - if (i >= 0) this._guiTweens.splice(i, 1); - } - return; - } - // === Задача 04: модал-сцены === - if (cmd === 'modal.open') { - try { - const mm = this.scene3d?.modalManager; - if (!mm) return; - // Резолв spotlights: строки-id → ref-объекты {kind:'primitive', id} как обычно - const opts = { ...(payload?.opts || {}) }; - if (Array.isArray(opts.spotlights)) { - opts.spotlights = opts.spotlights.map(r => this._normRef ? this._normRef(r) : r); - } - if (opts.cameraOverride && opts.cameraOverride.target) { - opts.cameraOverride = { - ...opts.cameraOverride, - target: this._normRef ? this._normRef(opts.cameraOverride.target) : opts.cameraOverride.target, - }; - } - const modalId = mm.open(opts); - // Подписка чтобы автоматически слать tweenDone-стиль событий - // на конкретный скрипт (тот кто открыл) — для onClose. - if (!mm._runtimeBoundOnClose) { - mm._runtimeBoundOnClose = true; - mm.onClose((closedId) => { - // Шлём событие всем sandbox'ам — они роутят к зарегистрированным fn - this.routeGlobalEvent?.('modalClosed', { id: closedId }); - }); - } - // Ответ обратно в worker: фактический modalId (юзер мог вернуть из open) - const sb = this.sandboxes.find(s => s.scriptId === scriptId); - if (sb && payload?.replyId != null) { - sb.sendGlobalEvent({ type: 'modalOpened', replyId: payload.replyId, modalId }); - } - } catch (e) { - this._log('error', 'modal.open failed: ' + (e?.message || e)); - } - return; - } - if (cmd === 'modal.close') { - try { - const mm = this.scene3d?.modalManager; - mm?.close?.(payload?.modalId); - } catch (e) {} - return; - } - if (cmd === 'modal.update') { - try { - const mm = this.scene3d?.modalManager; - mm?.update?.(payload?.modalId, payload?.patch); - } catch (e) {} - return; - } - // === Задача 01: Billboard 3D-таблички (см. BillboardUiManager) === - if (cmd === 'billboard.set' || cmd === 'billboard.update' || cmd === 'billboard.onClick') { - // Резолв ref → primitiveId. - // Worker может прислать ref сразу после game.scene.spawn — до - // того как main spawn'нул примитив и обновил _localToReal. - // Откладываем команду до резолва. - let ref = payload?.ref; - if (typeof ref === 'string' && ref.includes('_local_') - && !this._localToReal?.has(ref)) { - this._pendingResolveQueue = this._pendingResolveQueue || []; - this._pendingResolveQueue.push({ cmd, payload, scriptId }); - return; - } - try { - if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref); - let id = null; - if (typeof ref === 'string' && ref.startsWith('primitive:')) { - id = Number(ref.slice('primitive:'.length)); - } else if (Number.isFinite(ref)) { - id = Number(ref); - } - if (!Number.isFinite(id) || id == null) return; - const data = this.scene3d?.primitiveManager?.instances?.get(id); - if (!data || data.type !== 'billboard') return; - const mgr = this.scene3d?.billboardUiManager; - if (!mgr) return; - - if (cmd === 'billboard.set') { - mgr.applyToMesh(data, { - template: payload.template || data.billboard?.template || 'shop-item', - face: payload.face || data.billboard?.face || 'camera', - content: payload.content || data.billboard?.content, - elements: payload.elements || data.billboard?.elements, - }); - this.scheduleSceneSnapshot?.(); - } else if (cmd === 'billboard.update') { - // 2 формы: с elementId (точечно) или без (patch content) - if (typeof payload.elementId === 'string') { - mgr.update(data, payload.elementId, payload.patch || {}); - } else { - mgr.update(data, payload.patch || {}); - } - this.scheduleSceneSnapshot?.(); - } else if (cmd === 'billboard.onClick') { - const buttonId = String(payload.buttonId || 'buy'); - const realRef = 'primitive:' + id; - mgr.onClick(data, buttonId, () => { - const sb = this.sandboxes.find(s => s.scriptId === scriptId); - if (sb && typeof sb.sendGlobalEvent === 'function') { - // billboardClick роутится в worker'е через globalEvent-ветку - // (см. ScriptSandboxWorker.js cmd === 'globalEvent'). - sb.sendGlobalEvent({ - type: 'billboardClick', - ref: realRef, - button: buttonId, - }); - } - }); - } - } catch (e) { - this._log('error', cmd + ' failed: ' + (e?.message || e)); - } - return; - } - // eslint-disable-next-line no-console - console.warn('[GameRuntime] unknown cmd', cmd); - } - - /** - * Создать объект из скрипта. - * payload: { kind: 'block'|'model'|'primitive', subType, x, y, z, ref, ... } - * После создания обновляем `_localToReal` мапу — локальный ref ↔ реальный id. - */ - _applySceneSpawn(scriptId, payload) { - if (!payload) return; - const { kind, subType, ref } = payload; - if (!this._localToReal) this._localToReal = new Map(); - try { - if (kind === 'block') { - // color — для окрашиваемых блоков (studs-block); иначе игнорируется. - this.scene3d?.blockManager?.addBlock(payload.x, payload.y, payload.z, subType, payload.color); - // Для блоков ref детерминированный, но запоминаем — чтобы при - // Stop удалить заспавненные скриптом блоки (см. stop()). - if (ref) this._localToReal.set(ref, ref); - this.scheduleSceneSnapshot(); - } else if (kind === 'model') { - // addInstance возвращает Promise (async из-за GLB) - const opts = payload; - const p = this.scene3d?.modelManager?.addInstance( - subType, opts.x, opts.y, opts.z, opts.rotationY || 0 - ); - Promise.resolve(p).then((instId) => { - if (instId == null) return; - if (opts.name) { - const data = this.scene3d?.modelManager?.instances?.get(instId); - if (data) data.name = opts.name; - } - this._localToReal.set(ref, 'model:' + instId); - this._notifySpawnResolved(ref, 'model:' + instId); - this._drainPendingResolveQueue?.(ref); - this.scheduleSceneSnapshot(); - }).catch((err) => { - this._log('error', 'spawn model failed: ' + (err?.message || err)); - }); - } else if (kind === 'userModel') { - // Пользовательская воксельная модель: subType = 'user:'. - // addInstance возвращает Promise. - const opts = payload; - const p = this.scene3d?.userModelManager?.addInstance( - subType, opts.x, opts.y, opts.z, opts.rotationY || 0, - ); - Promise.resolve(p).then((instId) => { - if (instId == null) return; - if (opts.name) { - const data = this.scene3d?.userModelManager?.instances?.get(instId); - if (data) data.name = opts.name; - } - this._localToReal.set(ref, 'usermodel:' + instId); - this._notifySpawnResolved(ref, 'usermodel:' + instId); - this._drainPendingResolveQueue?.(ref); - this.scheduleSceneSnapshot(); - }).catch((err) => { - this._log('error', 'spawn user model failed: ' + (err?.message || err)); - }); - } else if (kind === 'primitive') { - const opts = payload; - const id = this.scene3d?.primitiveManager?.addInstance(subType, { - x: opts.x, y: opts.y, z: opts.z, - sx: opts.sx, sy: opts.sy, sz: opts.sz, - color: opts.color, material: opts.material, - rotationY: opts.rotationY, - name: opts.name, - brightness: opts.brightness, range: opts.range, - effect: opts.effect, - // textureAsset — картинка из ассетов проекта на грани. - ...(opts.textureAsset != null ? { textureAsset: opts.textureAsset } : {}), - // anchored:false → объект падает (физика unanchored). - // canCollide:false → проходимый (зона-триггер). - ...(opts.anchored != null ? { anchored: opts.anchored } : {}), - ...(opts.canCollide != null ? { canCollide: opts.canCollide } : {}), - ...(opts.visible != null ? { visible: opts.visible } : {}), - }); - if (id != null) { - this._localToReal.set(ref, 'primitive:' + id); - this._notifySpawnResolved(ref, 'primitive:' + id); - this._drainPendingResolveQueue?.(ref); - const data = this.scene3d?.primitiveManager?.instances?.get(id); - if (data) { - // Помечаем как заспавненный скриптом — движок шлёт - // для таких onPlayerTouch (нужно для «поймай объект»). - data._scriptSpawned = true; - // Если unanchored — регистрируем в физике на лету, - // иначе он не падает (start() уже отработал). - if (opts.anchored === false) { - this.scene3d?.dynamics?.registerPrimitive(data); - } - } - this.scheduleSceneSnapshot(); - } - } - } catch (e) { - this._log('error', 'scene.spawn failed: ' + (e?.message || e)); - } - } - - /** Удалить объект по ref (поддерживает локальный ref от spawn и реальный). */ - _applySceneDelete(payload) { - if (!payload?.ref) return; - let ref = payload.ref; - // Резолвим локальный ref → реальный - if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref); - // Ref всё ещё локальный ('_local_') — модель ещё не зарезолвилась - // (асинхронная загрузка GLB). Откладываем удаление: оно сработает - // в _notifySpawnResolved, когда реальный id появится. Без этого - // removeInstance(NaN) промахивался и объект «осиротевал» на сцене. - if (ref.indexOf('_local_') >= 0) { - if (!this._pendingDeletes) this._pendingDeletes = new Set(); - this._pendingDeletes.add(ref); - return; - } - try { - const colon = ref.indexOf(':'); - if (colon < 0) return; - const kind = ref.slice(0, colon); - const rest = ref.slice(colon + 1); - if (kind === 'block') { - const [xs, ys, zs] = rest.split(','); - this.scene3d?.blockManager?.removeBlock(Number(xs), Number(ys), Number(zs)); - } else if (kind === 'model') { - this.scene3d?.modelManager?.removeInstance(Number(rest)); - } else if (kind === 'primitive') { - this.scene3d?.primitiveManager?.removeInstance(Number(rest)); - } - // Удалили — снимаем mapping - for (const [k, v] of (this._localToReal || new Map()).entries()) { - if (v === ref) this._localToReal.delete(k); - } - this.scheduleSceneSnapshot(); - } catch (e) { - this._log('error', 'scene.delete failed: ' + (e?.message || e)); - } - } - - /** - * Запланировать рассылку sceneSnapshot всем sandbox'ам в следующем кадре. - * Делается отложенно чтобы при массовом spawn (например в onKey) отправить - * snapshot один раз, а не N раз. - */ - scheduleSceneSnapshot() { - if (this._snapshotPending) return; - this._snapshotPending = true; - // microtask — следующий кадр render-loop'а почти наверняка - Promise.resolve().then(() => { - this._snapshotPending = false; - this._broadcastSceneSnapshot(); - }); - } - - /** Рассылка snapshot всем sandbox'ам. */ - _broadcastSceneSnapshot() { - if (!this._isRunning || this.sandboxes.length === 0) return; - const snap = this._buildSceneSnapshot(); - for (const sb of this.sandboxes) { - sb.sendSceneSnapshot(snap); - } - } - - /** Запланировать рассылку GUI-snapshot всем sandbox'ам в следующем microtask. */ - scheduleGuiSnapshot() { - if (this._guiSnapshotPending) return; - this._guiSnapshotPending = true; - Promise.resolve().then(() => { - this._guiSnapshotPending = false; - this._broadcastGuiSnapshot(); - }); - } - - _broadcastGuiSnapshot() { - if (!this._isRunning || this.sandboxes.length === 0) return; - const snap = this._buildGuiSnapshot(); - for (const sb of this.sandboxes) { - sb.sendGuiSnapshot(snap); - } - } - - /** Запланировать рассылку snapshot атрибутов объектов (game.scene.setData). */ - scheduleDataSnapshot() { - if (this._dataSnapshotPending) return; - this._dataSnapshotPending = true; - Promise.resolve().then(() => { - this._dataSnapshotPending = false; - this._broadcastDataSnapshot(); - }); - } - - _broadcastDataSnapshot() { - if (!this._isRunning || this.sandboxes.length === 0) return; - for (const sb of this.sandboxes) { - sb.sendDataSnapshot(this._objectData); - } - } - - _buildGuiSnapshot() { - const list = this.scene3d?.getGuiElements?.() || []; - return list.map(g => ({ - id: g.id, type: g.type, name: g.name, - parentId: g.parentId || null, - x: g.x, y: g.y, w: g.w, h: g.h, anchor: g.anchor, - visible: g.visible !== false, - text: g.text, textColor: g.textColor, textSize: g.textSize, - bgColor: g.bgColor, bgOpacity: g.bgOpacity, - imageUrl: g.imageUrl, - placeholder: g.placeholder, - })); - } - - /** Собрать snapshot сцены для синхронных game.scene.find/all/getPosition в Worker'ах. */ - _buildSceneSnapshot() { - const blocks = []; - const models = []; - const primitives = []; - const s = this.scene3d; - if (s?.blockManager) { - for (const proxy of s.blockManager.blocks.values()) { - const md = proxy.metadata; - if (!md?.isBlock) continue; - blocks.push({ - ref: 'block:' + md.gridX + ',' + md.gridY + ',' + md.gridZ, - type: md.blockTypeId, - x: md.gridX, y: md.gridY, z: md.gridZ, - }); - } - } - if (s?.modelManager) { - for (const data of s.modelManager.instances.values()) { - models.push({ - ref: 'model:' + data.instanceId, - type: data.modelTypeId, - x: data.x, y: data.y, z: data.z, - name: data.name || null, - }); - } - } - if (s?.primitiveManager) { - for (const data of s.primitiveManager.instances.values()) { - primitives.push({ - ref: 'primitive:' + data.id, - type: data.type, - x: data.x, y: data.y, z: data.z, - // размеры/поворот нужны для game.physics.raycast (ray vs AABB) - sx: data.sx != null ? data.sx : 1, - sy: data.sy != null ? data.sy : 1, - sz: data.sz != null ? data.sz : 1, - rotationY: data.rotationY || 0, - visible: data.visible !== false, - name: data.name || null, - }); - } - } - return { blocks, models, primitives }; - } - - _applySelfMove(payload) { - if (!payload || !payload.target) return; - const t = payload.target; - const { x, y, z } = payload; - if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return; - try { - if (t.kind === 'model') { - let id = t.id ?? t.ref; - const mm = this.scene3d?.modelManager; - if (!mm) return; - // Локальный ref '_local_N' от scene.spawn → реальный id. - if (typeof id === 'string' && id.indexOf('_local_') === 0 - && this._localToReal) { - const real = this._localToReal.get('model:' + id); - if (real) { - const c2 = real.indexOf(':'); - id = c2 >= 0 ? real.slice(c2 + 1) : real; - } - } - let data = mm.instances.get(id); - if (!data && typeof id === 'string') { - const n = Number(id); - if (Number.isFinite(n)) data = mm.instances.get(n); - } - if (data) { - data.x = x; data.y = y; data.z = z; - if (data.rootMesh?.position) { - data.rootMesh.position.set(x, y, z); - if (data._worldMatrixFrozen) { - try { data.rootMesh.unfreezeWorldMatrix?.(); } catch (e) {} - if (Array.isArray(data.meshes)) { - for (const m of data.meshes) { - try { m?.unfreezeWorldMatrix?.(); } catch (e) {} - } - } - data._worldMatrixFrozen = false; - } - } - } - } else if (t.kind === 'primitive') { - const pm = this.scene3d?.primitiveManager; - if (!pm) return; - // _resolvePrimitiveId умеет и числовой id, и локальный - // ref '_local_N' (от scene.spawn) — без этого scene.move - // не находит объект, заспавненный скриптом. - const rid = this._resolvePrimitiveId(t.id ?? t.ref); - const data = rid != null ? pm.instances.get(rid) : null; - if (data) { - data.x = x; data.y = y; data.z = z; - if (data.mesh?.position) { - data.mesh.position.set(x, y, z); - if (data._worldMatrixFrozen) { - try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} - data._worldMatrixFrozen = false; - } - } - } - } else if (t.kind === 'userModel') { - // userModel-инстанс: отдельная нода (rootNode), не thin-instance. - // Двигаем root.position + обновляем data.x/y/z. - const id = t.id ?? t.ref; - const um = this.scene3d?.userModelManager; - if (!um) return; - let data = um.instances.get(id); - if (!data && typeof id === 'string') { - const n = Number(id); - if (Number.isFinite(n)) data = um.instances.get(n); - } - if (data) { - data.x = x; data.y = y; data.z = z; - if (data.rootNode?.position) { - data.rootNode.position.set(x, y, z); - } - } - } - // НЕ шлём sceneSnapshot при move — позиция объекта в snapshot всё - // равно стейл (sandbox использует findOne и сам не зависит от - // координат в snapshot). Иначе при анимации платформ (десятки - // scene.move в секунду) шлём весь snapshot 11000+ объектов в worker - // через структурный postMessage — это может стоить сотни мс. - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] self.move failed', e); - } - } - - _applySelfDelete(payload) { - if (!payload || !payload.target) return; - const t = payload.target; - try { - if (t.kind === 'block') { - const r = t.ref || t; - this.scene3d?.blockManager?.removeBlock(r.x, r.y, r.z); - } else if (t.kind === 'model') { - const id = t.id ?? t.ref; - this.scene3d?.modelManager?.removeInstance(id); - } else if (t.kind === 'primitive') { - const id = t.id ?? t.ref; - this.scene3d?.primitiveManager?.removeInstance(id); - } - this.scheduleSceneSnapshot(); - } catch (e) { - // eslint-disable-next-line no-console - console.warn('[GameRuntime] self.delete failed', e); - } - } - - _log(level, text) { - if (this._onLog) { - try { this._onLog({ level, text, ts: Date.now() }); } catch (e) { /* ignore */ } - } - } - - /** - * Воспроизвести встроенный звуковой эффект через Web Audio API. - * Все звуки генерируются процедурно — никаких mp3-файлов, нагрузка минимальная. - * Поддерживаемые: jump, pickup, win, lose, click, hit, coin. - */ - _playSound(payload) { - if (!payload || typeof payload.name !== 'string') return; - const name = payload.name; - const volume = Number.isFinite(payload.volume) ? Math.max(0, Math.min(2, payload.volume)) : 1; - const pitch = Number.isFinite(payload.pitch) ? Math.max(0.25, Math.min(4, payload.pitch)) : 1; - try { - if (!this._audioCtx) { - const Ctx = window.AudioContext || window.webkitAudioContext; - if (!Ctx) return; - this._audioCtx = new Ctx(); - } - const ctx = this._audioCtx; - if (ctx.state === 'suspended') ctx.resume(); - const t = ctx.currentTime; - // Описание звуков: одна или несколько oscillator-волн с envelope - switch (name) { - case 'jump': this._sfxJump(ctx, t, volume, pitch); break; - case 'pickup': this._sfxPickup(ctx, t, volume, pitch); break; - case 'win': this._sfxWin(ctx, t, volume, pitch); break; - case 'lose': this._sfxLose(ctx, t, volume, pitch); break; - case 'click': this._sfxClick(ctx, t, volume, pitch); break; - case 'hit': this._sfxHit(ctx, t, volume, pitch); break; - case 'coin': this._sfxCoin(ctx, t, volume, pitch); break; - default: - this._log('warn', `Неизвестный звук: ${name}`); - } - } catch (e) { - // ignore - } - } - - // === Звуковые пресеты (Web Audio) === - _sfxOsc(ctx, t, type, freq0, freq1, dur, vol) { - const osc = ctx.createOscillator(); - osc.type = type; - osc.frequency.setValueAtTime(freq0, t); - if (freq1 != null) osc.frequency.exponentialRampToValueAtTime(Math.max(1, freq1), t + dur); - const g = ctx.createGain(); - g.gain.setValueAtTime(0, t); - g.gain.linearRampToValueAtTime(vol, t + 0.005); - g.gain.exponentialRampToValueAtTime(0.001, t + dur); - osc.connect(g).connect(ctx.destination); - osc.start(t); - osc.stop(t + dur + 0.02); - } - _sfxJump(ctx, t, vol, pitch) { - // Похож на встроенный звук прыжка PlayerController. - this._sfxOsc(ctx, t, 'sine', 720 * pitch, 440 * pitch, 0.16, 0.22 * vol); - this._sfxOsc(ctx, t, 'sine', 110 * pitch, 60 * pitch, 0.07, 0.35 * vol); - } - _sfxPickup(ctx, t, vol, pitch) { - // Восходящие два тона — «пик-апнул!» - this._sfxOsc(ctx, t, 'square', 880 * pitch, 1320 * pitch, 0.10, 0.20 * vol); - this._sfxOsc(ctx, t + 0.08, 'square', 1320 * pitch, 1760 * pitch, 0.12, 0.16 * vol); - } - _sfxCoin(ctx, t, vol, pitch) { - // Классический «динь-динь» - this._sfxOsc(ctx, t, 'sine', 988 * pitch, 988 * pitch, 0.06, 0.25 * vol); - this._sfxOsc(ctx, t + 0.05, 'sine', 1318 * pitch, 1318 * pitch, 0.18, 0.25 * vol); - } - _sfxWin(ctx, t, vol, pitch) { - // Мажорный аккорд C-E-G по очереди - const notes = [523, 659, 784]; - notes.forEach((f, i) => { - this._sfxOsc(ctx, t + i * 0.08, 'triangle', f * pitch, f * pitch, 0.30, 0.22 * vol); - }); - } - _sfxLose(ctx, t, vol, pitch) { - // Нисходящий «провал» - this._sfxOsc(ctx, t, 'sawtooth', 440 * pitch, 110 * pitch, 0.45, 0.22 * vol); - this._sfxOsc(ctx, t + 0.08, 'sawtooth', 330 * pitch, 80 * pitch, 0.50, 0.18 * vol); - } - _sfxClick(ctx, t, vol, pitch) { - // Короткий «тик» - this._sfxOsc(ctx, t, 'square', 1500 * pitch, 800 * pitch, 0.04, 0.15 * vol); - } - /** - * Создать ParticleSystem в указанной точке. Авто-удаляется через duration сек. - * Делегирует в BabylonScene._spawnParticleEffect, который знает о Babylon. - */ - _spawnParticles(payload) { - if (!payload || !this.scene3d?._spawnParticleEffect) return; - try { - this.scene3d._spawnParticleEffect(payload); - } catch (e) { - this._log('error', 'spawnParticles failed: ' + (e?.message || e)); - } - } - - _sfxHit(ctx, t, vol, pitch) { - // Глухой «тук»: низкий sine + шумовой burst - this._sfxOsc(ctx, t, 'sine', 180 * pitch, 80 * pitch, 0.10, 0.30 * vol); - // Шум через короткий buffer-noise - const bufLen = Math.floor(ctx.sampleRate * 0.06); - const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate); - const data = buf.getChannelData(0); - for (let i = 0; i < bufLen; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / bufLen); - const src = ctx.createBufferSource(); - src.buffer = buf; - const lp = ctx.createBiquadFilter(); - lp.type = 'lowpass'; - lp.frequency.value = 1000 * pitch; - const g = ctx.createGain(); - g.gain.value = 0.18 * vol; - src.connect(lp).connect(g).connect(ctx.destination); - src.start(t); - } - - // === Универсальное хранилище сейвов (game.save.*) === - _saveProjectId() { - return this.scene3d?._currentProjectId || this.scene3d?.projectId || null; - } - _saveBaseUrl(namespace) { - const pid = this._saveProjectId(); - const uid = this.scene3d?._currentUserId; - if (!pid || !uid) return null; - const ns = encodeURIComponent(namespace || 'default'); - return `${STORYS_addres}/kubikon3d/savegame/${pid}/${uid}/${ns}`; - } - _saveReply(scriptId, reqId, result) { - for (const sb of this.sandboxes) { - if (sb.scriptId === scriptId) { - try { sb.worker.postMessage({ cmd: 'saveResponse', payload: { reqId, result } }); } catch (e) {} - return; - } - } - } - _saveGet(scriptId, payload) { - const reqId = payload?.reqId; - const url = this._saveBaseUrl(payload?.namespace); - if (!url) { this._saveReply(scriptId, reqId, null); return; } - // GET savegame теперь тоже требует JWT (бэк ужесточили после - // Этапа 4 — выдаёт 401 без, 403 если чужой). Используем те же - // headers что _saveSet/_saveMerge. - const headers = {}; - try { - const t = localStorage.getItem('Authorization'); - if (t) headers.Authorization = t; - } catch (e) {} - fetch(url, { headers }).then(r => r.json()) - .then(j => this._saveReply(scriptId, reqId, j.data ?? null)) - .catch(() => this._saveReply(scriptId, reqId, null)); - } - _saveGetAll(scriptId, payload) { - const reqId = payload?.reqId; - const pid = this._saveProjectId(); - const uid = this.scene3d?._currentUserId; - if (!pid || !uid) { this._saveReply(scriptId, reqId, {}); return; } - const headers = {}; - try { - const t = localStorage.getItem('Authorization'); - if (t) headers.Authorization = t; - } catch (e) {} - fetch(`${STORYS_addres}/kubikon3d/savegame/${pid}/${uid}`, { headers }) - .then(r => r.json()) - .then(j => this._saveReply(scriptId, reqId, j.namespaces || {})) - .catch(() => this._saveReply(scriptId, reqId, {})); - } - // Превращает относительный путь (/kubikon/gd, /kubikon3d, /app/...) - // во ВНЕШНИЙ URL правильного хоста, потому что в выделенном плеере - // (player.rublox.pro) этих SPA-роутов нет. - // - // Карта роутов (rublox.pro вместо mnk — у юзеров плеера нет сессии - // на mnk, разные домены/localStorage; получался 401): - // - http(s)://... → как есть (уже абсолютный) - // - /kubikon/gd* → rublox.pro/app/gd (порт меню GD) - // - /kubikon/play/N → ticket-flow в плеер уже идёт; - // сюда попасть можно только через - // app.navigate из скрипта уровня - // (Например 'играть ещё раз' → - // /kubikon/play/296?play=1&t=ts). - // Парсим id и шлём прямо в плеер. - // - /kubikon*, /kubikon3d* → rublox.pro/app (лента игр) - // - /app, /app/* → rublox.pro - // - всё остальное → rublox.pro/app (фоллбек) - // - // На localhost — dev-порт rublox-site (3004), на проде — rublox.pro. - _resolveExternalUrl(url) { - try { - // VITE_RUBLOX_HOME = главный сайт-витрина (default: https://rublox.pro/app). - // На dev rublox-site обычно крутится на :3004 — можно переопределить. - const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; - const RUBLOX_HOME = env.VITE_RUBLOX_HOME || 'https://rublox.pro/app'; - const rubloxBase = RUBLOX_HOME.replace(/\/app\/?$/, ''); // .../app → ... - - if (!url) return RUBLOX_HOME; - if (/^https?:\/\//i.test(url)) return url; - - // /kubikon/play/ — рестарт уровня. Перезагружаем плеер сам. - const playMatch = url.match(/^\/kubikon\/play\/(\d+)/); - if (playMatch) { - const playerBase = typeof window !== 'undefined' - ? `${window.location.protocol}//${window.location.host}` - : ''; - return `${playerBase}/${playMatch[1]}`; - } - // Legacy /kubikon/* роуты — редирект на главный сайт. - if (url.startsWith('/kubikon/gd')) return rubloxBase + '/app/gd'; - if (url.startsWith('/kubikon')) return rubloxBase + '/app'; - if (url.startsWith('/app')) return rubloxBase + url; - return rubloxBase + '/app'; - } catch (e) { - const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; - return env.VITE_RUBLOX_HOME || 'https://rublox.pro/app'; - } - } - // ВАЖНО: POST savegame/merge на бэке требует JWT (no_token → 401). - // Оригинальный код Майнкрафтии fetch БЕЗ Authorization-заголовка - // (этот же баг там тоже есть — сохранения GD-прогресса не работали - // молча, потому что .catch(()=>{}) глушит). В плеере добавляем JWT - // через зеркало localStorage['Authorization'] (см. auth/ticketExchange.js - // saveJWT — он кладёт JWT в оба ключа). - _saveAuthHeaders() { - const h = { 'Content-Type': 'application/json' }; - try { - const t = localStorage.getItem('Authorization'); - if (t) h.Authorization = t; - } catch (e) {} - return h; - } - _saveSet(payload) { - const url = this._saveBaseUrl(payload?.namespace); - if (!url) return; - try { - fetch(url, { - method: 'POST', - headers: this._saveAuthHeaders(), - body: JSON.stringify({ data: payload.data }), - }).catch(() => {}); - } catch (e) {} - } - _saveMerge(payload) { - const url = this._saveBaseUrl(payload?.namespace); - if (!url) return; - try { - fetch(url + '/merge', { - method: 'POST', - headers: this._saveAuthHeaders(), - body: JSON.stringify({ - patch: payload.patch || {}, - increment: payload.increment || {}, - max: payload.max || {}, - }), - }).catch(() => {}); - } catch (e) {} - } - _saveLeaderboard(scriptId, payload) { - const reqId = payload?.reqId; - const pid = this._saveProjectId(); - if (!pid) { this._saveReply(scriptId, reqId, []); return; } - const params = new URLSearchParams({ - namespace: payload?.namespace || '', - key: payload?.key || '', - order: payload?.order || 'desc', - limit: '20', - }); - fetch(`${STORYS_addres}/kubikon3d/savegame/${pid}/leaderboard?${params}`) - .then(r => r.json()) - .then(j => this._saveReply(scriptId, reqId, j.entries || [])) - .catch(() => this._saveReply(scriptId, reqId, [])); - } - - // ============== ECONOMY API (GD-reward через storys) ============== - // Каждый метод асинхронно делает HTTP-запрос с JWT в заголовке Authorization. - // Ответ возвращается в Worker через postMessage cmd='economyResponse'. - - _economyReply(scriptId, reqId, result) { - for (const sb of this.sandboxes) { - if (sb.scriptId === scriptId) { - try { sb.worker.postMessage({ cmd: 'economyResponse', payload: { reqId, result } }); } catch (e) {} - return; - } - } - } - - _economyAuthHeaders() { - const h = { 'Content-Type': 'application/json' }; - try { - const t = localStorage.getItem('Authorization'); - if (t) h.Authorization = t; - } catch (e) {} - return h; - } - - _economyReward(scriptId, payload) { - const reqId = payload?.reqId; - const aid = String(payload?.achievementId || ''); - if (!aid) { this._economyReply(scriptId, reqId, { ok: false, error: 'no_id' }); return; } - fetch(`${STORYS_addres}/kubikon3d/gd/reward`, { - method: 'POST', - headers: this._economyAuthHeaders(), - body: JSON.stringify({ achievement_id: aid }), - }) - .then(r => r.json()) - .then(j => this._economyReply(scriptId, reqId, j || { ok: false })) - .catch(e => this._economyReply(scriptId, reqId, { ok: false, error: String(e) })); - } - - _economyDailyCheck(scriptId, payload) { - const reqId = payload?.reqId; - fetch(`${STORYS_addres}/kubikon3d/gd/daily-check`, { - method: 'POST', - headers: this._economyAuthHeaders(), - body: JSON.stringify({}), - }) - .then(r => r.json()) - .then(j => this._economyReply(scriptId, reqId, j || { awarded: false })) - .catch(e => this._economyReply(scriptId, reqId, { awarded: false, error: String(e) })); - } - - _economyGetBalance(scriptId, payload) { - const reqId = payload?.reqId; - // Алмазы — user/api/v1/users/diamond, рейтинг — user/api/v1/users/rating. - // Делаем оба запроса параллельно. - const USER_BASE = STORYS_addres.replace('/api-storys', '/api-user'); - const headers = this._economyAuthHeaders(); - Promise.all([ - fetch(`${USER_BASE}/api/v1/users/diamond`, { headers }).then(r => r.json()).catch(() => ({ count: 0 })), - fetch(`${USER_BASE}/api/v1/users/rating`, { headers }).then(r => r.json()).catch(() => ({ rating: 0 })), - ]).then(([dm, rt]) => { - this._economyReply(scriptId, reqId, { - diamonds: Number(dm.count || 0), - rating: Number(rt.rating || 0), - }); - }).catch(() => this._economyReply(scriptId, reqId, { diamonds: 0, rating: 0 })); - } - - _economySpend(scriptId, payload) { - const reqId = payload?.reqId; - const amount = Number(payload?.amount || 0); - const reason = String(payload?.reason || 'gd_spend'); - if (amount < 1) { this._economyReply(scriptId, reqId, { ok: false, error: 'invalid_amount' }); return; } - const USER_BASE = STORYS_addres.replace('/api-storys', '/api-user'); - fetch(`${USER_BASE}/api/v1/users/diamond/spend`, { - method: 'POST', - headers: this._economyAuthHeaders(), - body: JSON.stringify({ amount, reason }), - }) - .then(r => r.json()) - .then(j => this._economyReply(scriptId, reqId, j || { ok: false })) - .catch(e => this._economyReply(scriptId, reqId, { ok: false, error: String(e) })); - } -} +/** + * GameRuntime — управляет всеми пользовательскими скриптами в режиме Play. + * + * Жизненный цикл: + * const rt = new GameRuntime(scene3d); + * rt.setOnLog(({level,text}) => console.log(text)); + * rt.start(scripts); // scripts — массив { id, code } + * ... каждый кадр rt.tick(dt) ... + * rt.stop(); // выгрузить всех Worker'ов + * + * Каждый скрипт = отдельный Worker. Команды от Worker'ов обрабатываются здесь + * и применяются к BabylonScene (через player.teleport и т.п.). + * + * Этап 2.1: минимальный API — player.teleport, onTick, log. + */ + +import { Color3 } from '@babylonjs/core'; +import { ScriptSandbox } from './ScriptSandbox'; +import { STORYS_addres } from '../api/API'; + +export class GameRuntime { + constructor(scene3d) { + this.scene3d = scene3d; + /** @type {ScriptSandbox[]} */ + this.sandboxes = []; + this._onLog = null; + this._isRunning = false; + // Активные твины (game.tween). Крутятся в tick(dt). + // Каждый: { tweenId, scriptId, ref, props, from, duration, easing, + // delay, repeat, yoyo, elapsed, delayLeft, dir, loopsLeft } + this._tweens = []; + // Атрибуты объектов (game.scene.setData/getData). { ref: { key: value } }. + // Общие для всех скриптов, рассылаются воркерам через dataSnapshot. + this._objectData = {}; + // Интерактивные объекты (game.self.onInteract / ProximityPrompt). + // Каждый: { target, text, distance, key }. Заполняется при + // self.registerInteract, проверяется по дистанции в tick. + this._interactables = []; + // ref ближайшего интерактивного объекта в зоне (для подсветки [E]). + this._activeInteractRef = null; + // Общее состояние комнаты для game.room.set/get (Фаза 4.3). + // В редакторе (single-player) — локальное хранилище. С Colyseus- + // комнатой будет синхронизироваться (требует серверной схемы). + this._roomState = {}; + // Сессии игроков, которых видели в прошлом tick — для детекта + // join/leave (game.onPlayerJoin / onPlayerLeave). + this._seenSessions = null; + // Команды (Фаза 4.4): name → { name, color }. + this._teams = new Map(); + // Команда локального игрока (имя) или null. + this._localPlayerTeam = null; + } + + setOnLog(cb) { this._onLog = cb; } + + /** Колбэк HUD-команд от скриптов: { cmd, payload }. */ + setOnHud(cb) { this._onHud = cb; } + + /** Колбэк смены прицела через скрипт: (type) — UI обновляет overlay. */ + setOnCrosshairChange(cb) { this._onCrosshair = cb; } + + /** + * Запустить все скрипты. + * @param {Array<{id:any, code:string}>} scripts + */ + start(scripts) { + this.stop(); + this._isRunning = true; + // eslint-disable-next-line no-console + console.log('[GameRuntime] start called with scripts:', scripts); + if (!Array.isArray(scripts) || scripts.length === 0) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] start: no scripts to run'); + return; + } + // Карта модулей для game.require — { имя_скрипта: код }. + // Любой скрипт проекта можно подключить как модуль по его имени. + const modules = {}; + for (const s of scripts) { + if (s && typeof s.name === 'string' && s.name && typeof s.code === 'string') { + modules[s.name] = s.code; + } + } + // Первичный snapshot сцены — собираем СИНХРОННО ДО запуска скриптов и + // передаём прямо в init. Иначе findOne() в синхронном теле скрипта + // (на старте) возвращает null → подписки obj.onTouch/find не работают. + let initialScene = null; + try { initialScene = this._buildSceneSnapshot(); } catch (e) { initialScene = null; } + for (const s of scripts) { + if (!s || typeof s.code !== 'string' || !s.code.trim()) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] skipping invalid script entry', s); + continue; + } + const sb = new ScriptSandbox(s.code, s.target || null); + sb.scriptId = s.id; + sb.setModules(modules); + if (initialScene) sb.setInitialScene(initialScene); + // Если target есть — передаём начальную позицию self до старта + if (s.target) { + const pos = this._collectSelfPosition(s.target); + if (pos) sb.setInitialSelfPosition(pos); + } + sb.setOnCommand((cmd, payload) => { + // PERF-METRICS: замер скриптов (postMessage→handle) + const _t0 = performance.now(); + this._handleCommand(s.id, cmd, payload); + const m = this.scene3d?._perfMetrics; + if (m) { + m.script_ms_sum += performance.now() - _t0; + m.script_count++; + } + }); + sb.start(); + this.sandboxes.push(sb); + // eslint-disable-next-line no-console + console.log('[GameRuntime] sandbox started for script id=', s.id); + } + this._log('info', `Запущено скриптов: ${this.sandboxes.length}`); + // Подцепляемся на изменения HP игрока, чтобы шлать событие 'hpChange' + // во все sandbox'ы. Не перезаписываем существующий обработчик — + // оборачиваем его (старый колбэк UI должен продолжать работать). + try { + const player = this.scene3d?.player; + if (player && !player._gameRuntimeHpHook) { + const prevCb = player._onHpChange; + this._lastSeenHp = player.hp ?? 100; + player._onHpChange = (ev) => { + if (typeof prevCb === 'function') { + try { prevCb(ev); } catch (e) {} + } + const delta = (ev?.hp ?? 0) - (this._lastSeenHp ?? 0); + this._lastSeenHp = ev?.hp ?? 0; + this.routeGlobalEvent('hpChange', { + hp: ev?.hp, + maxHp: ev?.maxHp, + source: ev?.source || null, + damaged: !!ev?.damaged, + delta, + }); + }; + player._gameRuntimeHpHook = true; + } + // Хуки прыжка/приземления для game.onPlayerJump / game.onPlayerLand + if (player && !player._gameRuntimeMoveHook) { + player._onJump = () => this.routeGlobalEvent('playerJump', {}); + player._onLand = () => this.routeGlobalEvent('playerLand', {}); + player._gameRuntimeMoveHook = true; + } + // Флаг для детекта смерти (game.onPlayerDied) — проверяется в tick + this._playerWasAlive = (this.scene3d?.player?.hp ?? 100) > 0; + // Хук смерти NPC (game.scene.onNpcDeath / npc.onDeath) — событие + // npcDeath с id и позицией погибшего NPC. + const nm = this.scene3d?.npcManager; + if (nm && typeof nm.setOnDeath === 'function') { + nm.setOnDeath((npcId, position) => { + this.routeGlobalEvent('npcDeath', { npcId, position }); + }); + } + } catch (e) { /* ignore */ } + // Первичный snapshot — нужен чтобы game.scene.find/all и game.gui.find работали с самого начала. + const sendInitial = () => { + this._broadcastSceneSnapshot(); + this._broadcastGuiSnapshot(); + this._broadcastTerrainHeightmap(); + this._broadcastSkinsSnapshot(); // задача 07 + // Задача 03: запустить animationPreset для GUI-элементов с пресетом ≠ 'none'. + this._startGuiAnimationPresets(); + }; + if (typeof requestAnimationFrame !== 'undefined') { + requestAnimationFrame(sendInitial); + } else { + setTimeout(sendInitial, 16); + } + } + + /** + * Разослать карту высот гладкого ландшафта всем sandbox'ам. + * Нужно для game.scene.surfaceY(x,z). Снимается raycast'ом по + * реальному мешу один раз — террейн в Play не меняется. + */ + _broadcastTerrainHeightmap() { + const s = this.scene3d; + if (!s || typeof s.exportRobloxHeightmap !== 'function') return; + // Шаг 3м — компромисс: меньше точек (~14K при 360м) чем у зомби + // (там шаг 2), для плавности движения животных достаточно. + let hm; + try { + hm = s.exportRobloxHeightmap(3); + } catch (e) { + return; + } + if (!hm || !hm.heights) return; + const payload = { + origin: hm.origin, step: hm.step, + cols: hm.cols, rows: hm.rows, heights: hm.heights, + }; + for (const sb of this.sandboxes) { + sb.sendTerrainHeightmap(payload); + } + } + + /** Задача 03: запустить пресеты анимаций для GUI-элементов в Play. */ + _startGuiAnimationPresets() { + const gm = this.scene3d?.guiManager; + if (!gm) return; + if (!this._guiTweens) this._guiTweens = []; + for (const el of (gm.elements || [])) { + const preset = el.animationPreset; + if (!preset || preset === 'none') continue; + const id = el.id; + // Каждый пресет = одна tween-запись с reverses+repeat=-1 + switch (preset) { + case 'pulse': + this._guiTweens.push(this._mkGuiPreset(id, el, + { scaleX: 1.1, scaleY: 1.1 }, 0.7, 'ease', true, -1)); + break; + case 'rotate': + this._guiTweens.push(this._mkGuiPreset(id, el, + { rotation: (el.rotation || 0) + 360 }, 3, 'linear', false, -1)); + break; + case 'sway': + this._guiTweens.push(this._mkGuiPreset(id, el, + { rotation: (el.rotation || 0) + 8 }, 0.6, 'ease', true, -1)); + this._guiTweens[this._guiTweens.length - 1].start.rotation = (el.rotation || 0) - 8; + break; + case 'glow': + this._guiTweens.push(this._mkGuiPreset(id, el, + { bgOpacity: 0.6 }, 0.8, 'ease', true, -1)); + break; + case 'bounce': + this._guiTweens.push(this._mkGuiPreset(id, el, + { y: (el.y || 50) - 1 }, 0.5, 'ease', true, -1)); + break; + } + } + } + _mkGuiPreset(id, el, targetProps, duration, easing, reverses, repeat) { + const start = {}; + for (const k of Object.keys(targetProps)) { + if (k === 'scaleX' || k === 'scaleY') start[k] = el[k] || 1; + else if (k === 'rotation') start[k] = el.rotation || 0; + else if (k === 'bgOpacity') start[k] = el.bgOpacity == null ? 1 : el.bgOpacity; + else start[k] = el[k] || 0; + } + return { + tweenId: ++this._tweenSeq || (this._tweenSeq = 1), + scriptId: '__preset__', + realId: id, + start, target: targetProps, + elapsed: 0, delay: 0, + duration, easing, + repeat, reverses, iter: 0, dir: 1, + }; + } + + /** + * Задача 07: разослать снапшот скинов всем sandbox'ам — чтобы + * game.player.getAvailableSkins/getAllSkins работали синхронно. + * Манифест грузится через fetch (кешируется браузером), затем + * объединяется с разблокированными скинами из scene.skins. + */ + async _broadcastSkinsSnapshot() { + try { + this._ensureSkinState(); + let manifest = this._skinManifestCache; + if (!manifest) { + const resp = await fetch('/kubikon-assets/characters/skins_manifest.json'); + const json = await resp.json(); + manifest = (json.skins || []).map(s => ({ + slug: s.slug || (s.id || '').replace(/^skin_/, ''), + name: s.name || s.slug, + kind: s.kind || 'r15', + category: s.category || 'human', + price: Number.isFinite(s.price) ? s.price : 0, + })); + // Встроенные «человеки» character-a..g тоже добавим как базовый выбор. + this._skinManifestCache = manifest; + } + const payload = { + all: manifest, + unlocked: Array.from(this._skinState.unlocked), + current: this._skinState.current, + coins: this._skinState.coins, + }; + for (const sb of this.sandboxes) sb.sendSkinsSnapshot(payload); + // Также отдать снапшот в scene для React-магазина. + try { this.scene3d?._setSkinShopData?.({ ...payload, manifestFull: this._skinManifestCache }); } catch (e) {} + } catch (e) { + // манифест недоступен — не критично, скрипт получит пустой список + } + } + + /** + * Задача 07: гарантированно инициализировать состояние скинов при первом + * обращении. Держит множество разблокированных скинов и текущий. + */ + _ensureSkinState() { + if (this._skinState) return this._skinState; + const sk = this.scene3d?._skinsConfig || {}; + const def = sk.default || this.scene3d?._playerModelType || 'character-a'; + const defSlug = this._slugFromTypeId(def); + const unlocked = new Set(Array.isArray(sk.unlocked) ? sk.unlocked : []); + unlocked.add(defSlug); + this._skinState = { + unlocked, + current: defSlug, + shopVisible: sk.shopVisible !== false, + coins: Number.isFinite(sk.coins) ? sk.coins : 0, + }; + return this._skinState; + } + + /** Ленивая инициализация PlacementManager (задача 11). */ + _ensurePlacementManager() { + if (this.scene3d?.placementManager) return this.scene3d.placementManager; + if (!this.scene3d || !this.scene3d.scene) return null; + try { + if (this.scene3d._PlacementManagerClass) { + this.scene3d.placementManager = new this.scene3d._PlacementManagerClass(this.scene3d); + } + } catch (e) { this._log('error', 'placementManager init: ' + (e?.message || e)); } + return this.scene3d.placementManager || null; + } + + /** Ленивая инициализация виджета слот-инвентаря магазина (задача 11). */ + _ensureShopInventory() { + if (this.scene3d?.shopInventoryUi) return this.scene3d.shopInventoryUi; + if (!this.scene3d) return null; + try { + if (this.scene3d._ShopInventoryUiClass) { + this.scene3d.shopInventoryUi = new this.scene3d._ShopInventoryUiClass(this.scene3d); + } + } catch (e) { this._log('error', 'shopInventoryUi init: ' + (e?.message || e)); } + return this.scene3d.shopInventoryUi || null; + } + + /** slug → _modelTypeId движка. Встроенные → 'skin_', character-* как есть. */ + _resolveSkinTypeId(slug) { + if (!slug) return 'character-a'; + if (slug.startsWith('character-')) return slug; + if (slug.startsWith('skin_') || slug.startsWith('customskin:')) return slug; + return 'skin_' + slug; + } + + /** _modelTypeId → slug (обратно). */ + _slugFromTypeId(typeId) { + if (!typeId) return 'character-a'; + if (typeId.startsWith('skin_')) return typeId.slice('skin_'.length); + return typeId; + } + + /** Задача 03: обновить GUI-твины (gui.tween + animationPresets). */ + _updateGuiTweens(dt) { + const gm = this.scene3d?.guiManager; + if (!gm) return; + for (let i = this._guiTweens.length - 1; i >= 0; i--) { + const tw = this._guiTweens[i]; + if (tw.delay > 0) { tw.delay -= dt; if (tw.delay > 0) continue; } + tw.elapsed += dt; + let t = tw.elapsed / tw.duration; + let done = false; + if (t >= 1) { t = 1; done = true; } + const raw = tw.dir === -1 ? 1 - t : t; + const k = GameRuntime._ease(tw.easing, raw); + // Применяем + const el = gm.elements.find(e => e.id === tw.realId); + if (!el) { this._guiTweens.splice(i, 1); continue; } + const patch = {}; + for (const key of Object.keys(tw.target)) { + const from = tw.start[key]; + const to = tw.target[key]; + if (typeof from === 'number' && typeof to === 'number') { + patch[key] = from + (to - from) * k; + } else if (typeof from === 'string' && typeof to === 'string' + && from.startsWith('#') && to.startsWith('#')) { + patch[key] = GameRuntime._lerpColor(from, to, k); + } else { + // Прочее — на конце ставим целевое + if (done) patch[key] = to; + } + } + // Throttle: обновляем не чаще чем раз в 32мс (~30 FPS). + tw._lastApply = tw._lastApply || 0; + tw._lastApply += dt; + if (tw._lastApply >= 0.032 || done) { + tw._lastApply = 0; + try { gm.update(tw.realId, patch); } catch (e) {} + } + + if (done) { + if (tw.reverses && tw.dir === 1) { + tw.dir = -1; + tw.elapsed = 0; + continue; + } + tw.iter++; + if (tw.repeat === -1 || tw.iter < tw.repeat) { + // повтор + tw.elapsed = 0; + tw.dir = 1; + continue; + } + // готово + this._guiTweens.splice(i, 1); + // onDone callback в worker + const sb = this.sandboxes.find(s => s.scriptId === tw.scriptId); + if (sb) sb.sendGlobalEvent({ type: 'tweenDone', tweenId: tw.tweenId }); + } + } + } + + /** Слить отложенные команды для конкретного только что зарезолвленного ref. */ + _drainPendingResolveQueue(resolvedLocalRef) { + if (!this._pendingResolveQueue || !this._pendingResolveQueue.length) return; + const stay = []; + for (const item of this._pendingResolveQueue) { + if (item.payload?.ref === resolvedLocalRef) { + this._handleCommand(item.scriptId, item.cmd, item.payload); + } else { + stay.push(item); + } + } + this._pendingResolveQueue = stay; + } + + /** + * Получить позицию объекта по его target (для зеркалирования в worker). + */ + _collectSelfPosition(target) { + if (!target || !this.scene3d) return null; + try { + if (target.kind === 'block') { + const r = target.ref || target; + return { x: r.x, y: r.y + 0.5, z: r.z }; + } + if (target.kind === 'model') { + const data = this.scene3d.modelManager?.instances?.get(target.id ?? target.ref); + if (data) return { x: data.x, y: data.y, z: data.z }; + } + if (target.kind === 'primitive') { + const data = this.scene3d.primitiveManager?.instances?.get(target.id ?? target.ref); + if (data) return { x: data.x, y: data.y, z: data.z }; + } + if (target.kind === 'userModel') { + const data = this.scene3d.userModelManager?.instances?.get(target.id ?? target.ref); + if (data) return { x: data.x, y: data.y, z: data.z }; + } + } catch (e) { /* ignore */ } + return null; + } + + stop() { + if (this.sandboxes.length > 0) { + this._log('info', 'Остановка скриптов'); + // eslint-disable-next-line no-console + console.log('[GameRuntime] stopping', this.sandboxes.length, 'sandboxes'); + for (const sb of this.sandboxes) sb.stop(); + } + // Удаляем все объекты, которые скрипты наспавнили через + // game.scene.spawn/clone — иначе после Stop они остаются на сцене + // и накапливаются при повторных запусках. + this._cleanupSpawnedObjects(); + // Удаляем GUI-элементы, созданные скриптом через game.gui.create — + // иначе после Stop они остаются в интерфейсе сцены. + this._cleanupSpawnedGui(); + // Убираем billboard-метки над объектами (game.scene.setLabel). + try { + if (this.scene3d?._labelManager) this.scene3d._labelManager.clearAll(); + } catch (e) { /* ignore */ } + this.sandboxes = []; + this._isRunning = false; + this._soloScriptId = null; + this._tweens = []; + this._objectData = {}; + this._interactables = []; + this._activeInteractRef = null; + this._watchedTouchRefs = null; + this._watchedClickRefs = null; + this._roomState = {}; + this._seenSessions = null; + this._teams = new Map(); + this._localPlayerTeam = null; + this._constraintLocalToReal = new Map(); + this._fxLocalToReal = new Map(); + this._soundLocalToReal = new Map(); + this._guiLocalToReal = new Map(); + this._guiRealToLocal = new Map(); + } + + /** + * Удалить GUI-элементы, созданные скриптом через game.gui.create. + * Вызывается в stop() — иначе скриптовый интерфейс остаётся в сцене + * после остановки игры и копится при повторных запусках. + */ + _cleanupSpawnedGui() { + if (!this._guiLocalToReal || this._guiLocalToReal.size === 0) return; + const s = this.scene3d; + if (!s || typeof s.removeGuiElement !== 'function') return; + for (const realId of this._guiLocalToReal.values()) { + try { + // removeGuiElement каскадно удаляет детей — повторный вызов + // для уже удалённого элемента безопасен (no-op). + s.removeGuiElement(realId); + } catch (e) { /* ignore */ } + } + // removeGuiElement дёргает _notify GuiManager → KubikonEditor + // синхронит guiList. Снапшот воркерам не нужен (они остановлены). + } + + /** Удалить со сцены все объекты, созданные скриптами в Play-режиме. */ + _cleanupSpawnedObjects() { + if (!this._localToReal || this._localToReal.size === 0) return; + const s = this.scene3d; + for (const realRef of this._localToReal.values()) { + try { + if (typeof realRef !== 'string') continue; + const colon = realRef.indexOf(':'); + if (colon < 0) continue; + const kind = realRef.slice(0, colon); + const rest = realRef.slice(colon + 1); + if (kind === 'block') { + const [xs, ys, zs] = rest.split(','); + s?.blockManager?.removeBlock(Number(xs), Number(ys), Number(zs)); + } else if (kind === 'model') { + s?.modelManager?.removeInstance(Number(rest)); + } else if (kind === 'primitive') { + s?.primitiveManager?.removeInstance(Number(rest)); + } else if (kind === 'usermodel') { + // Воксельные модели, наспавненные скриптом (placement) — + // удаляем при Stop, иначе placed-объекты остаются. См. studio. + s?.userModelManager?.removeInstance(Number(rest)); + } + } catch (e) { /* ignore — объект мог быть уже удалён скриптом */ } + } + this._localToReal = new Map(); + } + + /** + * Запустить ОДИН скрипт без перезагрузки сцены — режим отладки. + * Останавливает другие скрипты, оставляет только заданный. + * Это альтернатива Play-режиму: без полноценного игрока, без физики, но + * скрипты получают зеркало state и могут вызывать game.log/teleport. + * + * Используется из ScriptEditor → кнопка «Запустить только этот». + */ + startSolo(script) { + this.stop(); + this._isRunning = true; + this._soloScriptId = script?.id || null; + if (!script || typeof script.code !== 'string' || !script.code.trim()) { + this._log('warn', 'Solo-запуск: пустой код'); + return; + } + const sb = new ScriptSandbox(script.code, script.target || null); + sb.scriptId = script.id; + if (script.target) { + const pos = this._collectSelfPosition(script.target); + if (pos) sb.setInitialSelfPosition(pos); + } + sb.setOnCommand((cmd, payload) => { + const _t0 = performance.now(); + this._handleCommand(script.id, cmd, payload); + const m = this.scene3d?._perfMetrics; + if (m) { + m.script_ms_sum += performance.now() - _t0; + m.script_count++; + } + }); + sb.start(); + this.sandboxes.push(sb); + this._log('info', `Отладочный запуск: ${script.id}`); + } + + /** True если runtime работает в solo-режиме (один скрипт). */ + isSolo() { return !!this._soloScriptId; } + getSoloScriptId() { return this._soloScriptId; } + + /** + * Вызывать каждый кадр в Play-режиме. + * dt в секундах. + */ + tick(dt) { + if (!this._isRunning || this.sandboxes.length === 0) return; + const state = this._collectState(); + for (const sb of this.sandboxes) { + // Для скриптов с target — добавляем актуальную позицию self + const stateForSb = sb.target + ? { ...state, selfPosition: this._collectSelfPosition(sb.target) } + : state; + sb.tick(dt, stateForSb); + } + // Анимации game.tween + if (this._tweens.length > 0) this._updateTweens(dt); + // Задача 03: GUI tweens + if (this._guiTweens && this._guiTweens.length > 0) this._updateGuiTweens(dt); + + // ProximityPrompt — подсказка [E] над ближайшим интерактивным объектом + if (this._interactables.length > 0) this._updateInteractables(); + + // Детект смерти игрока — событие game.onPlayerDied (один раз на смерть) + const hp = this.scene3d?.player?.hp ?? 100; + const aliveNow = hp > 0; + if (this._playerWasAlive && !aliveNow) { + this.routeGlobalEvent('playerDied', {}); + } + this._playerWasAlive = aliveNow; + + // Детект join/leave игроков комнаты (Фаза 4.3). + this._detectPlayerJoinLeave(state.players); + } + + /** + * Сравнить текущий список игроков с прошлым tick — событие + * playerJoin для новых, playerLeave для исчезнувших. + * Локального игрока не учитываем (он не «присоединяется»). + */ + _detectPlayerJoinLeave(players) { + if (!players || !players.list) return; + const now = new Map(); + for (const p of players.list) { + if (!p.isLocal) now.set(p.sessionId, p); + } + if (this._seenSessions == null) { + // Первый tick — фиксируем без событий (это «уже были»). + this._seenSessions = now; + return; + } + for (const [sid, p] of now) { + if (!this._seenSessions.has(sid)) { + this.routeGlobalEvent('playerJoin', { + sessionId: sid, name: p.name, + }); + } + } + for (const [sid, p] of this._seenSessions) { + if (!now.has(sid)) { + this.routeGlobalEvent('playerLeave', { + sessionId: sid, name: p.name, + }); + } + } + this._seenSessions = now; + } + + /** + * Запустить твин: зарезолвить ref, снять стартовые значения, добавить в _tweens. + * payload: { tweenId, ref, props, duration, easing, delay, repeat, yoyo } + */ + _startTween(scriptId, payload) { + try { + const { tweenId, ref, props } = payload || {}; + if (tweenId == null || typeof ref !== 'string' || !props) return; + const from = {}; + let guiId = null; + + // --- цель: GUI или 3D-объект --- + // GUI-id: либо локальный ref (gui.create), либо реальный id + let resolvedGuiId = ref; + if (this._guiLocalToReal?.has(ref)) resolvedGuiId = this._guiLocalToReal.get(ref); + const guiList = this.scene3d?.getGuiElements?.() || []; + const guiEl = guiList.find(g => g.id === resolvedGuiId); + + if (guiEl) { + guiId = resolvedGuiId; + // числовые свойства GUI + for (const key of ['x', 'y', 'w', 'h', 'bgOpacity', 'textSize']) { + if (props[key] != null && guiEl[key] != null) from[key] = Number(guiEl[key]); + } + // цвет + if (props.color != null && guiEl.bgColor) { + from._color = Color3.FromHexString(guiEl.bgColor); + from._colorTo = Color3.FromHexString(String(props.color)); + } + if (props.textColor != null && guiEl.textColor) { + from._color = Color3.FromHexString(guiEl.textColor); + from._colorTo = Color3.FromHexString(String(props.textColor)); + } + } else { + // 3D-объект + const tgt = this._resolveTweenTarget(ref); + if (!tgt) { + this._log('error', 'tween: объект не найден — ' + ref); + return; + } + const d = tgt.data; + from.x = d.x || 0; from.y = d.y || 0; from.z = d.z || 0; + from.rotationX = d.rotationX || 0; + from.rotationY = d.rotationY || 0; + from.rotationZ = d.rotationZ || 0; + from.sx = d.sx != null ? d.sx : 1; + from.sy = d.sy != null ? d.sy : 1; + from.sz = d.sz != null ? d.sz : 1; + from.opacity = d.opacity != null ? d.opacity + : (d.mesh?.material?.alpha != null ? d.mesh.material.alpha : 1); + if (props.color != null) { + const cur = d.color || '#ffffff'; + from._color = Color3.FromHexString(cur); + from._colorTo = Color3.FromHexString(String(props.color)); + } + } + + this._tweens.push({ + tweenId, scriptId, ref, guiId, + props, from, + duration: Math.max(0, Number(payload.duration) || 0), + easing: payload.easing || 'ease', + delayLeft: Math.max(0, Number(payload.delay) || 0), + loopsLeft: Number(payload.repeat) || 0, // 0=без повтора, -1=бесконечно + yoyo: !!payload.yoyo, + elapsed: 0, + dir: 1, + }); + } catch (e) { + this._log('error', 'tween.start failed: ' + (e?.message || e)); + } + } + + /** + * ProximityPrompt: каждый кадр ищем ближайший интерактивный объект + * в радиусе и показываем подсказку «[E] ...» над ним (HUD-метка). + */ + _updateInteractables() { + const player = this.scene3d?.player; + const pp = player?._pos; + if (!pp) return; + const halfH = player?.HALF_H ?? 0.9; + const px = pp.x, py = pp.y - halfH, pz = pp.z; + + let nearest = null; + let nearestD2 = Infinity; + for (const it of this._interactables) { + const objPos = this._resolveInteractPos(it); + if (!objPos) continue; + const dx = objPos.x - px, dy = objPos.y - py, dz = objPos.z - pz; + const d2 = dx*dx + dy*dy + dz*dz; + const r = it.distance; + if (d2 <= r*r && d2 < nearestD2) { + nearestD2 = d2; + nearest = it; + } + } + + const nearestRef = nearest ? nearest.ref : null; + if (nearestRef !== this._activeInteractRef) { + this._activeInteractRef = nearestRef; + if (nearest) { + // показываем подсказку через HUD (как game.ui.set) + if (this._onHud) { + try { + this._onHud({ cmd: 'ui.set', payload: { + id: '__interact', + text: '[' + nearest.key.toUpperCase() + '] ' + nearest.text, + opts: { x: 50, y: 75, color: '#ffe44a', size: 20 }, + } }); + } catch (e) { /* ignore */ } + } + } else { + // вышли из зоны — убираем подсказку + if (this._onHud) { + try { + this._onHud({ cmd: 'ui.set', payload: { id: '__interact', text: null } }); + } catch (e) { /* ignore */ } + } + } + } + } + + /** Резолв позиции интерактивного объекта (по ref). */ + _resolveInteractPos(it) { + const tgt = this._resolveTweenTarget(it.ref); + if (tgt) { + const d = tgt.data; + return { x: d.x || 0, y: d.y || 0, z: d.z || 0 }; + } + return null; + } + + /** + * Нажата клавиша взаимодействия (E) — отправить событие 'interact' + * скрипту ближайшего интерактивного объекта. Вызывается из routeGlobalEvent + * при keydown. + */ + _tryInteract(key) { + if (!this._activeInteractRef) return; + const it = this._interactables.find(x => x.ref === this._activeInteractRef); + if (!it || it.key !== String(key).toLowerCase()) return; + // событие 'interact' скрипту с target = этим объектом + this.routeEvent(it.target, 'interact', {}); + } + + /** Прокрутка всех активных твинов на dt секунд. */ + _updateTweens(dt) { + for (let i = this._tweens.length - 1; i >= 0; i--) { + const tw = this._tweens[i]; + // задержка перед стартом + if (tw.delayLeft > 0) { + tw.delayLeft -= dt; + if (tw.delayLeft > 0) continue; + dt = -tw.delayLeft; // остаток времени уходит в анимацию + } + tw.elapsed += dt; + let t = tw.duration > 0 ? tw.elapsed / tw.duration : 1; + let done = false; + if (t >= 1) { + t = 1; + done = true; + } + // прогресс с учётом направления (yoyo) + easing + const raw = tw.dir === -1 ? 1 - t : t; + const k = GameRuntime._ease(tw.easing, raw); + this._applyTweenFrame(tw, k); + + if (done) { + if (tw.yoyo && tw.dir === 1) { + // первый проход «туда» завершён — разворачиваем «обратно» + tw.dir = -1; + tw.elapsed = 0; + continue; + } + // цикл завершён полностью (или прямой, или yoyo туда-обратно) + if (tw.loopsLeft !== 0) { + if (tw.loopsLeft > 0) tw.loopsLeft--; + tw.dir = 1; + tw.elapsed = 0; + continue; + } + // твин закончен — снять и уведомить скрипт + this._tweens.splice(i, 1); + this._notifyTweenDone(tw.scriptId, tw.tweenId); + } + } + } + + /** Easing-функции. Принимают t∈[0,1], возвращают сглаженное значение. */ + static _ease(name, t) { + switch (name) { + case 'linear': + return t; + case 'bounce': { + const n1 = 7.5625, d1 = 2.75; + if (t < 1 / d1) return n1 * t * t; + if (t < 2 / d1) { t -= 1.5 / d1; return n1 * t * t + 0.75; } + if (t < 2.5 / d1) { t -= 2.25 / d1; return n1 * t * t + 0.9375; } + t -= 2.625 / d1; return n1 * t * t + 0.984375; + } + case 'elastic': { + if (t === 0 || t === 1) return t; + const c4 = (2 * Math.PI) / 3; + return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1; + } + case 'back': { + const c1 = 1.70158, c3 = c1 + 1; + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); + } + case 'ease': + default: + // ease-in-out (плавный старт и финиш) + return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + } + } + + /** Уведомить воркер скрипта что твин доиграл (resolve onDone). */ + _notifyTweenDone(scriptId, tweenId) { + const sb = this.sandboxes.find(s => s.scriptId === scriptId); + if (sb && sb.worker) { + try { sb.worker.postMessage({ cmd: 'tweenDone', payload: { tweenId } }); } catch (e) {} + } + } + + /** + * Сообщить ВСЕМ sandbox'ам маппинг локальный ref → реальный после + * scene.spawn. Нужно чтобы синхронные read-методы воркера + * (getPosition и т.п.) резолвили локальный ref в реальный — иначе + * заспавненный объект не находится в _sceneIndex (там реальные ref). + */ + _notifySpawnResolved(localRef, realRef) { + if (!localRef || !realRef) return; + // Объект мог быть удалён скриптом ДО того как зарезолвился + // (асинхронный спавн GLB-модели). Если он в очереди отложенных + // удалений — удаляем сейчас, когда реальный id известен. + if (this._pendingDeletes && this._pendingDeletes.has(localRef)) { + this._pendingDeletes.delete(localRef); + try { + this._applySceneDelete({ ref: realRef }); + } catch (e) { /* ignore */ } + return; + } + for (const sb of this.sandboxes) { + if (sb && sb.worker) { + try { + sb.worker.postMessage({ + cmd: 'spawnResolved', + payload: { localRef, realRef }, + }); + } catch (e) { /* ignore */ } + } + } + } + + /** + * Резолв ref в инстанс-данные объекта сцены. + * Возвращает { kind, data } или null. kind: 'primitive'|'model'|'userModel'. + * data — объект из *Manager.instances (имеет mesh/rootMesh/rootNode + x/y/z). + */ + /** + * Резолв id примитива из любого вида ссылки в реальный id для + * primitiveManager.instances. Принимает: + * - реальный числовой id (или строку-число) + * - локальный ref от spawn/clone ('primitive:_local_N') + * - ref 'primitive:realId' + * Возвращает id (число) или null. + */ + _resolvePrimitiveId(idOrRef) { + if (idOrRef == null) return null; + const pm = this.scene3d?.primitiveManager; + if (!pm) return null; + let v = idOrRef; + if (typeof v === 'string') { + // полный ref 'primitive:_local_N' / 'primitive:123' → резолвим через карту + if (this._localToReal?.has(v)) v = this._localToReal.get(v); + const colon = v.indexOf(':'); + if (colon >= 0) v = v.slice(colon + 1); + // голый '_local_N' (воркер мог отрезать 'primitive:') — ищем по карте: + // ключ 'primitive:_local_N' → значение 'primitive:realId'. + if (typeof v === 'string' && v.indexOf('_local_') === 0 && this._localToReal) { + const full = 'primitive:' + v; + if (this._localToReal.has(full)) { + const real = this._localToReal.get(full); + const c2 = real.indexOf(':'); + v = c2 >= 0 ? real.slice(c2 + 1) : real; + } + } + } + // прямой id + if (pm.instances.has(v)) return v; + const n = Number(v); + if (Number.isFinite(n) && pm.instances.has(n)) return n; + return null; + } + + /** + * ref NPC ('npc:_local_N' от воркера или 'npc:') → числовой npcId. + * Возвращает number или null. + */ + _resolveNpcId(ref) { + if (typeof ref !== 'string') return null; + let v = ref; + // Локальный ref воркера → реальный 'npc:'. + if (this._localToReal?.has(v)) v = this._localToReal.get(v); + const colon = v.indexOf(':'); + if (colon < 0) return null; + const id = Number(v.slice(colon + 1)); + return Number.isFinite(id) ? id : null; + } + + /** + * Выполнить NPC-команду. Если NPC ещё не создан (spawnNpc async, а + * скрипт сразу зовёт follow/moveTo/say) — откладываем команду в + * очередь по локальному ref и проигрываем после npcSpawned-резолва. + * Без этого команды сразу после spawnNpc молча терялись. + */ + _npcCmd(ref, fn) { + const nid = this._resolveNpcId(ref); + if (nid != null) { fn(nid); return; } + // ещё не резолвится — откладываем (только для локальных ref NPC) + if (typeof ref === 'string' && ref.indexOf('npc:_local_') === 0) { + if (!this._pendingNpcCmds) this._pendingNpcCmds = new Map(); + if (!this._pendingNpcCmds.has(ref)) this._pendingNpcCmds.set(ref, []); + this._pendingNpcCmds.get(ref).push(fn); + } + } + + /** Проиграть отложенные команды для NPC после его резолва. */ + _flushPendingNpcCmds(localRef, npcId) { + if (!this._pendingNpcCmds) return; + const queue = this._pendingNpcCmds.get(localRef); + if (!queue) return; + this._pendingNpcCmds.delete(localRef); + for (const fn of queue) { + try { fn(npcId); } catch (e) { /* ignore */ } + } + } + + /** Локальный ref связи ('constraint:_local_N') → числовой id или null. */ + _resolveConstraintId(ref) { + if (typeof ref !== 'string') return null; + if (this._constraintLocalToReal?.has(ref)) { + return this._constraintLocalToReal.get(ref); + } + // Запасной путь: прямой числовой id в строке. + const colon = ref.indexOf(':'); + const id = Number(colon >= 0 ? ref.slice(colon + 1) : ref); + return Number.isFinite(id) ? id : null; + } + + /** Локальный ref луча/следа ('fx:_local_N') → числовой id или null. */ + _resolveFxId(ref) { + if (typeof ref !== 'string') return null; + if (this._fxLocalToReal?.has(ref)) { + return this._fxLocalToReal.get(ref); + } + const colon = ref.indexOf(':'); + const id = Number(colon >= 0 ? ref.slice(colon + 1) : ref); + return Number.isFinite(id) ? id : null; + } + + _resolveTweenTarget(ref) { + if (typeof ref !== 'string') return null; + // Локальный ref из scene.spawn ('primitive:_local_N') → реальный id + if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref); + const colon = ref.indexOf(':'); + const kind = colon >= 0 ? ref.slice(0, colon) : null; + const rawId = colon >= 0 ? ref.slice(colon + 1) : ref; + const tryGet = (mgr) => { + if (!mgr || !mgr.instances) return null; + let d = mgr.instances.get(rawId); + if (!d) { + const n = Number(rawId); + if (Number.isFinite(n)) d = mgr.instances.get(n); + } + return d || null; + }; + if (kind === 'primitive' || kind == null) { + const d = tryGet(this.scene3d?.primitiveManager); + if (d) return { kind: 'primitive', data: d }; + } + if (kind === 'model' || kind == null) { + const d = tryGet(this.scene3d?.modelManager); + if (d) return { kind: 'model', data: d }; + } + const um = tryGet(this.scene3d?.userModelManager); + if (um) return { kind: 'userModel', data: um }; + return null; + } + + /** + * Применить промежуточное состояние твина к объекту. + * k — сглаженный прогресс [0,1]. Интерполяция from→props по каждому ключу. + */ + _applyTweenFrame(tw, k) { + const lerp = (a, b) => a + (b - a) * k; + // --- GUI-элемент --- + if (tw.guiId != null) { + const patch = {}; + for (const key of Object.keys(tw.props)) { + if (key === 'color' || key === 'textColor') continue; + if (tw.from[key] == null) continue; + patch[key] = lerp(tw.from[key], Number(tw.props[key])); + } + if (tw.props.color != null || tw.props.textColor != null) { + const ck = tw.props.color != null ? 'color' : 'textColor'; + patch[ck] = GameRuntime._lerpColor(tw.from._color, tw.from._colorTo, k); + } + // обновляем напрямую — без scheduleGuiSnapshot (дорого каждый кадр) + try { this.scene3d?.updateGuiElement?.(tw.guiId, patch); } catch (e) {} + return; + } + // --- 3D-объект --- + const tgt = this._resolveTweenTarget(tw.ref); + if (!tgt) return; + const d = tgt.data; + const p = tw.props, f = tw.from; + // позиция + let posChanged = false; + if (p.x != null) { d.x = lerp(f.x, Number(p.x)); posChanged = true; } + if (p.y != null) { d.y = lerp(f.y, Number(p.y)); posChanged = true; } + if (p.z != null) { d.z = lerp(f.z, Number(p.z)); posChanged = true; } + // поворот + let rotChanged = false; + if (p.rotationX != null) { d.rotationX = lerp(f.rotationX || 0, Number(p.rotationX)); rotChanged = true; } + if (p.rotationY != null) { d.rotationY = lerp(f.rotationY || 0, Number(p.rotationY)); rotChanged = true; } + if (p.rotationZ != null) { d.rotationZ = lerp(f.rotationZ || 0, Number(p.rotationZ)); rotChanged = true; } + // масштаб + let scaleChanged = false; + if (p.sx != null) { d.sx = lerp(f.sx || 1, Number(p.sx)); scaleChanged = true; } + if (p.sy != null) { d.sy = lerp(f.sy || 1, Number(p.sy)); scaleChanged = true; } + if (p.sz != null) { d.sz = lerp(f.sz || 1, Number(p.sz)); scaleChanged = true; } + // меш (primitive → .mesh, model/userModel → .rootMesh/.rootNode) + const mesh = d.mesh || d.rootMesh || d.rootNode; + if (mesh) { + if (posChanged && mesh.position) mesh.position.set(d.x, d.y, d.z); + if (rotChanged && mesh.rotation) { + mesh.rotation.x = d.rotationX || 0; + mesh.rotation.y = d.rotationY || 0; + mesh.rotation.z = d.rotationZ || 0; + } + if (scaleChanged && mesh.scaling) { + mesh.scaling.set(d.sx || 1, d.sy || 1, d.sz || 1); + } + // размороз world-matrix если был заморожен + if ((posChanged || rotChanged || scaleChanged) && d._worldMatrixFrozen) { + try { mesh.unfreezeWorldMatrix?.(); } catch (e) {} + d._worldMatrixFrozen = false; + } + } + // цвет + if (p.color != null && f._color != null && mesh?.material) { + const c = GameRuntime._lerpColor3(f._color, f._colorTo, k); + mesh.material.diffuseColor = c; + if (d.material === 'neon') mesh.material.emissiveColor = c; + d.color = '#' + c.toHexString().slice(1); + } + // прозрачность + if (p.opacity != null && mesh?.material) { + const op = lerp(f.opacity != null ? f.opacity : 1, Number(p.opacity)); + mesh.material.alpha = op; + d.opacity = op; + } + } + + /** Интерполяция цвета (Babylon Color3) между двумя hex. */ + static _lerpColor3(from, to, k) { + return new Color3( + from.r + (to.r - from.r) * k, + from.g + (to.g - from.g) * k, + from.b + (to.b - from.b) * k, + ); + } + + /** Интерполяция цвета → hex-строка (для GUI). */ + static _lerpColor(from, to, k) { + return '#' + GameRuntime._lerpColor3(from, to, k).toHexString().slice(1); + } + + /** + * Маршрутизация событий объектов к скриптам с соответствующим target. + * Вызывается из BabylonScene при клике/touch. + * + * @param {object} target — {kind, ref|x|y|z|id} + * @param {string} eventType — 'click' | 'touch' + * @param {object} extra — дополнительные данные события + */ + routeEvent(target, eventType, extra = {}) { + if (!target || !eventType) return; + for (const sb of this.sandboxes) { + if (!sb.target) continue; + if (!this._targetMatches(sb.target, target)) continue; + sb.sendEvent({ type: eventType, ...extra }); + } + } + + /** + * Глобальное событие — доставляется ВСЕМ sandbox'ам (не зависит от target). + * Используется для onKey, onClick (глобальный), onPlayerTouch. + */ + routeGlobalEvent(eventType, extra = {}) { + if (!eventType) return; + // Спецслучай: guiClick приходит с realId. Worker мог подписаться двумя + // способами: + // 1) по локальному ref, который вернул gui.create() — '_gui_local_N' + // 2) по ЯВНОМУ id, который сам передал в gui.create({ id: 'fight' }), + // или по name элемента. + // Раньше мы ПЕРЕЗАПИСЫВАЛИ extra.id на localRef — это ломало вариант (2), + // потому что worker искал handler по localRef, а юзер подписался по + // явному id. Теперь шлём ОБА id (`id` = реальный + `localId` = ref), + // worker матчит по обоим (см. _guiHandlerKeys в ScriptSandboxWorker). + if ((eventType === 'guiClick' || eventType === 'guiSubmit' + || eventType === 'guiTextChange') + && extra && extra.id != null && this._guiRealToLocal) { + const local = this._guiRealToLocal.get(extra.id); + if (local && local !== extra.id) extra = { ...extra, localId: local }; + } + // ProximityPrompt: keydown клавиши взаимодействия → событие interact + if (eventType === 'keydown' && extra && extra.key + && this._interactables.length > 0) { + this._tryInteract(extra.key); + } + for (const sb of this.sandboxes) { + sb.sendGlobalEvent({ type: eventType, ...extra }); + } + } + + /** + * Адресное событие касания/клика КОНКРЕТНОГО объекта по его ref. + * Доставляется всем sandbox'ам как globalEvent с type='instTouch'|... + ref; + * worker матчит по ref на findOne(x).onTouch/onUntouch/onClick. + */ + routeInstEvent(ref, type, extra = {}) { + if (!ref || !type) return; + this.routeGlobalEvent(type, { ref, ...extra }); + } + + /** + * Уведомление: моб убит. Рассылается во все скрипты как 'mobKilled'. + * Скрипт может подписаться через `game.onMobKilled(fn)`. + * payload: { type: 'zombie' | ..., x, y, z } + */ + notifyMobKilled(mobType, position) { + this.routeGlobalEvent('mobKilled', { mobType, position }); + } + + /** Совпадает ли target скрипта с обращённым target события. */ + _targetMatches(a, b) { + if (!a || !b) return false; + if (a.kind !== b.kind) return false; + if (a.kind === 'block') { + const ar = a.ref || a; + const br = b.ref || b; + return ar.x === br.x && ar.y === br.y && ar.z === br.z; + } + const aId = a.id ?? a.ref; + const bId = b.id ?? b.ref; + return aId === bId; + } + + /** Собрать снимок state для отправки в Worker'ы. */ + _collectState() { + const player = this.scene3d?.player; + // PlayerController хранит позицию в this._pos (Vector3). + // Внутри _pos.y — это центр капсулы (учтена HALF_H ~= 0.9), для авторов + // удобнее давать «низ ног» = _pos.y - HALF_H. + const p = player?._pos; + const halfH = player?.HALF_H ?? 0.9; + const position = p + ? { x: p.x, y: p.y - halfH, z: p.z } + : { x: 0, y: 0, z: 0 }; + // Yaw/pitch (для player.forward) + const yaw = player?._yaw || 0; + const pitch = player?._pitch || 0; + // Forward-вектор. PlayerController использует: + // fx = sin(yaw)*cos(pitch), fy = -sin(pitch), fz = cos(yaw)*cos(pitch) + const cosP = Math.cos(pitch); + const forward = { + x: Math.sin(yaw) * cosP, + y: -Math.sin(pitch), + z: Math.cos(yaw) * cosP, + }; + const crosshair = this.scene3d?.getCrosshair ? this.scene3d.getCrosshair() : 'none'; + const hp = player?.hp ?? 100; + const maxHp = player?.maxHp ?? 100; + // Снимок мобов (зомби) — для game.scene.mobs() из скриптов + let mobs = []; + try { + const zm = this.scene3d?.zombieManager; + if (zm && typeof zm.getMobsSnapshot === 'function') { + mobs = zm.getMobsSnapshot(); + } + } catch (e) {} + // Снимок NPC — для game.scene.npcs() и npc.position из скриптов. + let npcs = []; + try { + const nm = this.scene3d?.npcManager; + if (nm && typeof nm.getSnapshot === 'function') { + npcs = nm.getSnapshot(); + } + } catch (e) {} + // Снимок инвентаря — для game.inventory.has/list/active. + let inventory = null; + try { + const inv = this.scene3d?.inventory; + if (inv) { + inventory = { + slots: inv.slots.map(s => s ? { + kind: s.kind, modelTypeId: s.modelTypeId, name: s.name, + } : null), + activeIndex: inv.activeIndex, + }; + } + } catch (e) {} + // Снимок игроков комнаты — для game.players.* (Фаза 4.3). + // В редакторе (single-player) — только локальный игрок. + // С мультиплеером — локальный + все remote из _mpSync. + const players = this._collectPlayers(position, hp, maxHp); + // Кубикон Dash: текущее направление гравитации (+1 / -1). + // Нужно скрипту для рендера куба в правильной ориентации. + const gravityDir = player?._gravityDir ?? 1; + // Состояние игрока ('ground'|'air'|'water') для game.player.state. + const state = player?._playerState || 'ground'; + // Зажатые клавиши — для game.player.isKeyDown(key). + // _codes хранит коды ('KeyW','Space','ArrowUp'), нормализуем в имена скрипта. + const keys = {}; + if (player?._codes) { + for (const code of player._codes) { + const k = GameRuntime._normalizeKeyCode(code); + if (k) keys[k] = true; + } + } + return { + player: { position, yaw, pitch, forward, crosshair, hp, maxHp, gravityDir, state, keys }, + mobs, + npcs, + inventory, + players, + roomState: this._roomState || {}, + teams: this._teams ? Array.from(this._teams.values()) : [], + }; + } + + /** + * Снимок всех игроков комнаты для game.players.* (Фаза 4.3). + * Локальный игрок всегда первый, sessionId='local' в одиночной игре + * или реальный sessionId если есть Colyseus-комната. + * Возвращает { me, list } — list включает me. + */ + _collectPlayers(myPos, myHp, myMaxHp) { + const mp = this.scene3d?._mpSync; + const mySessionId = mp?.room?.sessionId || 'local'; + const myName = mp?.room?.state?.players?.get?.(mySessionId)?.username + || this._localPlayerName || 'Игрок'; + const me = { + sessionId: mySessionId, + name: myName, + isLocal: true, + position: myPos, + hp: myHp, maxHp: myMaxHp, + team: this._localPlayerTeam || null, + }; + const list = [me]; + // Remote-игроки из MultiplayerSync (если есть комната). + if (mp && mp.remotePlayers) { + const roomPlayers = mp.room?.state?.players; + for (const rp of mp.remotePlayers.values()) { + // team берётся из Colyseus-state (его синхронизирует сервер). + const colyP = roomPlayers?.get?.(rp.sessionId); + list.push({ + sessionId: rp.sessionId, + name: rp.username || rp.sessionId, + isLocal: false, + position: rp.current + ? { x: rp.current.x, y: rp.current.y, z: rp.current.z } + : { x: 0, y: 0, z: 0 }, + hp: rp.hp ?? 100, maxHp: rp.maxHp ?? 100, + team: (colyP && colyP.team) || null, + }); + } + } + return { me, list }; + } + + /** Код клавиши Babylon ('KeyW','Space','ArrowUp') → имя для скрипта ('w','space','arrowup'). */ + static _normalizeKeyCode(code) { + if (!code) return null; + if (code.startsWith('Key')) return code.slice(3).toLowerCase(); // KeyW → w + if (code.startsWith('Digit')) return code.slice(5); // Digit1 → 1 + if (code.startsWith('Arrow')) return code.toLowerCase(); // ArrowUp → arrowup + const map = { + Space: 'space', ShiftLeft: 'shift', ShiftRight: 'shift', + Enter: 'enter', Escape: 'escape', + ControlLeft: 'ctrl', ControlRight: 'ctrl', + }; + return map[code] || code.toLowerCase(); + } + + /** Команда от Worker'а пришла — применяем на сцене. */ + _handleCommand(scriptId, cmd, payload) { + if (cmd === 'log') { + this._log(payload?.level || 'info', payload?.text || ''); + return; + } + // inst.watchTouch / inst.watchClick — скрипт подписался на касание/клик + // ПРОИЗВОЛЬНОГО объекта (findOne(x).onTouch/onClick). Движок начинает + // следить за AABB этого объекта в _detectTouchEvents и слать обратно + // instTouch/instUntouch (через routeInstEvent). + if (cmd === 'inst.watchTouch') { + const ref = payload && payload.ref; + if (typeof ref === 'string') { + if (!this._watchedTouchRefs) this._watchedTouchRefs = new Set(); + this._watchedTouchRefs.add(ref); + } + return; + } + if (cmd === 'inst.watchClick') { + const ref = payload && payload.ref; + if (typeof ref === 'string') { + if (!this._watchedClickRefs) this._watchedClickRefs = new Set(); + this._watchedClickRefs.add(ref); + } + return; + } + if (cmd === 'player.teleport') { + const player = this.scene3d?.player; + if (player && player._pos && payload) { + const { x, y, z } = payload; + if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) { + try { + const halfH = player.HALF_H ?? 0.9; + // Конвертируем «низ ног» обратно в центр капсулы + player._pos.set(x, y + halfH, z); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] teleport failed', e); + } + } + } else { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] teleport ignored — no player or _pos', { hasPlayer: !!player, hasPos: !!(player && player._pos) }); + } + return; + } + if (cmd === 'player.setLaneX') { + // Сдвиг игрока ТОЛЬКО по X — не трогает Z и Y. Нужно для + // раннеров (смена полосы): teleport(x,y,z) затирал бы Z, + // отменяя продвижение autorun каждый кадр. + const player = this.scene3d?.player; + if (player && player._pos && payload) { + const x = Number(payload.x); + if (Number.isFinite(x)) { + try { player._pos.x = x; } catch (e) { /* ignore */ } + } + } + return; + } + if (cmd === 'player.damage') { + const player = this.scene3d?.player; + if (player && typeof player.takeDamage === 'function') { + const amt = Math.max(0, Number(payload?.amount) || 0); + if (amt > 0) { + // Если урон больше maxHp — обходим i-frames для kill(). + if (amt >= (player.maxHp ?? 100)) { + player._lastDamageTime = 0; // сбрасываем cooldown + } + try { player.takeDamage(amt, 'script'); } catch (e) {} + } + } + return; + } + if (cmd === 'player.heal') { + const player = this.scene3d?.player; + if (player && typeof player.hp === 'number') { + const amt = Math.max(0, Number(payload?.amount) || 0); + player.hp = Math.min(player.maxHp ?? 100, player.hp + amt); + if (player._onHpChange) { + try { player._onHpChange({ hp: player.hp, maxHp: player.maxHp, source: 'heal', damaged: false }); } catch (e) {} + } + } + return; + } + if (cmd === 'player.respawn') { + const player = this.scene3d?.player; + if (player && player._pos) { + // Восстанавливаем HP + player.hp = player.maxHp ?? 100; + if (player._onHpChange) { + try { player._onHpChange({ hp: player.hp, maxHp: player.maxHp, source: 'respawn', damaged: false }); } catch (e) {} + } + // Возвращаем модель если была спрятана при смерти + if (player._modelRoot) player._modelRoot.setEnabled(true); + // Телепорт на spawnPoint сцены + const sp = this.scene3d?._spawnPoint + || this.scene3d?.scene?.metadata?.spawnPoint + || { x: 0, y: 1, z: 0 }; + const halfH = player.HALF_H ?? 0.9; + try { player._pos.set(sp.x, sp.y + halfH, sp.z); } catch (e) {} + // Сбросим скорость падения + if (player._velocity) { + try { player._velocity.set(0, 0, 0); } catch (e) {} + } + } + return; + } + if (cmd === 'player.setSpawn') { + // Назначить активную точку возрождения. Меняем scene3d._spawnPoint — + // им пользуется player.respawn и логика смерти. + const s = this.scene3d; + if (s && payload) { + let sp = null; + if (typeof payload.ref === 'string') { + // ref объекта: встаём НАД ним (центр + полувысота + зазор). + const ref = payload.ref; + if (ref.indexOf('block:') === 0) { + const [bx, by, bz] = ref.slice(6).split(',').map(Number); + if ([bx, by, bz].every(Number.isFinite)) { + sp = { x: bx, y: by + 1.1, z: bz }; + } + } else { + const tgt = this._resolveTweenTarget(ref); + if (tgt && tgt.data) { + const d = tgt.data; + const topOff = (d.sy != null ? d.sy * 0.5 : 0.5) + 0.1; + sp = { x: d.x, y: (d.y || 0) + topOff, z: d.z }; + } + } + } else if (Number.isFinite(payload.x)) { + sp = { x: payload.x, y: payload.y, z: payload.z }; + } + if (sp && typeof s.setSpawnPoint === 'function') { + s.setSpawnPoint(sp.x, sp.y, sp.z); + } + } + return; + } + // === NPC API (Фаза 4.1) === + if (cmd === 'npc.spawn') { + // payload: { modelType, ref, x, y, z, rotationY, hp, name, speed } + const nm = this.scene3d?.npcManager; + if (nm && payload) { + if (!this._localToReal) this._localToReal = new Map(); + const p = nm.spawnNpc(payload.modelType, { + x: payload.x, y: payload.y, z: payload.z, + rotationY: payload.rotationY, + hp: payload.hp, name: payload.name, speed: payload.speed, + }); + Promise.resolve(p).then((npcId) => { + if (npcId == null) { + this._log('error', 'spawnNpc не удался: ' + payload.modelType); + return; + } + // Локальный ref воркера → реальный 'npc:'. + if (payload.ref) { + this._localToReal.set(payload.ref, 'npc:' + npcId); + // Проигрываем команды, отправленные скриптом сразу + // после spawnNpc (follow/moveTo/say) — они ждали + // резолва ref в очереди. + this._flushPendingNpcCmds(payload.ref, npcId); + } + // Сообщаем воркеру маппинг localRef → npcId, чтобы + // npc.onDeath по локальному ref находил правильного NPC. + if (payload.ref) { + const sb = this.sandboxes.find(s => s.scriptId === scriptId); + if (sb && sb.worker) { + try { + sb.worker.postMessage({ + cmd: 'npcSpawned', + payload: { localRef: payload.ref, npcId }, + }); + } catch (e) { /* ignore */ } + } + } + }).catch((err) => { + this._log('error', 'spawnNpc failed: ' + (err?.message || err)); + }); + } + return; + } + if (cmd === 'npc.moveTo') { + // _npcCmd откладывает команду, если NPC ещё не создан (async). + this._npcCmd(payload?.ref, (nid) => + this.scene3d?.npcManager?.moveTo(nid, payload.x, payload.z)); + return; + } + if (cmd === 'npc.follow') { + this._npcCmd(payload?.ref, (nid) => { + // target — ref объекта или 'player'. Резолвим локальный ref + // в реальный (объект мог быть заспавнен скриптом). + let target = payload?.target; + if (typeof target === 'string' && this._localToReal?.has(target)) { + target = this._localToReal.get(target); + } + this.scene3d?.npcManager?.follow(nid, target); + }); + return; + } + if (cmd === 'npc.stop') { + this._npcCmd(payload?.ref, (nid) => + this.scene3d?.npcManager?.stopNpc(nid)); + return; + } + if (cmd === 'npc.setSpeed') { + this._npcCmd(payload?.ref, (nid) => + this.scene3d?.npcManager?.setSpeed(nid, payload?.speed)); + return; + } + if (cmd === 'npc.say') { + this._npcCmd(payload?.ref, (nid) => + this.scene3d?.npcManager?.say(nid, payload?.text, payload?.duration)); + return; + } + if (cmd === 'npc.damage') { + this._npcCmd(payload?.ref, (nid) => + this.scene3d?.npcManager?.damage(nid, payload?.amount)); + return; + } + if (cmd === 'npc.remove') { + this._npcCmd(payload?.ref, (nid) => + this.scene3d?.npcManager?.removeNpc(nid)); + return; + } + // === Constraints / связи объектов (Фаза 5) === + if (cmd === 'constraint.create') { + // payload: { kind: 'weld'|'hinge'|'spring', localRef, ... } + const cm = this.scene3d?.constraintManager; + if (cm && payload) { + let id = null; + if (payload.kind === 'weld') { + id = cm.addWeld(payload.refA, payload.refB); + } else if (payload.kind === 'hinge') { + id = cm.addHinge(payload.ref, { + pivotX: payload.pivotX, pivotZ: payload.pivotZ, + angle: payload.angle, + }); + } else if (payload.kind === 'spring') { + id = cm.addSpring(payload.ref, { + stiffness: payload.stiffness, damping: payload.damping, + }); + } + if (id == null) { + this._log('error', 'не удалось создать связь ' + payload.kind); + } else if (payload.localRef) { + // Маппинг localRef → реальный id (как у NPC). + if (!this._constraintLocalToReal) this._constraintLocalToReal = new Map(); + this._constraintLocalToReal.set(payload.localRef, id); + } + } + return; + } + if (cmd === 'constraint.hingeAngle') { + const cid = this._resolveConstraintId(payload?.ref); + if (cid != null) this.scene3d?.constraintManager?.setHingeAngle(cid, payload?.deg); + return; + } + if (cmd === 'constraint.springPush') { + const cid = this._resolveConstraintId(payload?.ref); + if (cid != null) { + this.scene3d?.constraintManager?.pushSpring( + cid, payload?.vx, payload?.vy, payload?.vz); + } + return; + } + if (cmd === 'constraint.remove') { + const cid = this._resolveConstraintId(payload?.ref); + if (cid != null) this.scene3d?.constraintManager?.remove(cid); + return; + } + // === Beam / Trail — лучи и следы (Фаза 5.2) === + // === Placement mode (задача 11) === + if (cmd === 'placement.start') { + const pm = this._ensurePlacementManager(); + if (pm && payload) { + pm.setCallbacks({ + onPlace: (res) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'placeConfirm', ...res }); }, + onCancel: () => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'placeCancel' }); }, + onMove: (mv) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'placeMove', ...mv }); }, + }); + try { pm.start(payload.itemKey, payload.opts || {}); } + catch (e) { this._log('error', 'placement.start: ' + (e?.message || e)); } + } + return; + } + if (cmd === 'placement.cancel') { this.scene3d?.placementManager?.cancel(); return; } + if (cmd === 'placement.confirm') { this.scene3d?.placementManager?.confirm(); return; } + if (cmd === 'placement.rotate') { this.scene3d?.placementManager?.rotate(payload?.deg); return; } + if (cmd === 'inventoryUi.create') { + const im = this._ensureShopInventory(); + if (im && payload) { + try { im.create(payload, (item) => { for (const sb of this.sandboxes) sb.sendGlobalEvent({ type: 'invUiSlotClick', key: item.key, item }); }); } + catch (e) { this._log('error', 'inventoryUi.create: ' + (e?.message || e)); } + } + return; + } + if (cmd === 'inventoryUi.setBalance') { + this.scene3d?.shopInventoryUi?.setBalance(payload?.currency, payload?.amount); + this.scene3d?.placementManager?.setBalance(payload?.currency, payload?.amount); + return; + } + if (cmd === 'inventoryUi.remove') { this.scene3d?.shopInventoryUi?.remove(); return; } + + if (cmd === 'fx.create') { + // payload: { kind: 'beam'|'trail', localRef, ... } + const bm = this.scene3d?.beamManager; + if (bm && payload) { + let id = null; + if (payload.kind === 'beam') { + id = bm.addBeam({ + from: payload.from, to: payload.to, + color: payload.color, width: payload.width, + // Задача 08: расширенные опции луча. + texture: payload.texture, customTextureUrl: payload.customTextureUrl, + textureMode: payload.textureMode, textureSpeed: payload.textureSpeed, + textureScale: payload.textureScale, + strokeColor: payload.strokeColor, strokeWidth: payload.strokeWidth, + colorSequence: payload.colorSequence, + transparencySequence: payload.transparencySequence, + widthSequence: payload.widthSequence, + faceMode: payload.faceMode, segments: payload.segments, + curved: payload.curved, curveHeight: payload.curveHeight, + attachOffset: payload.attachOffset, ignoreDepth: payload.ignoreDepth, + }); + } else if (payload.kind === 'trail') { + id = bm.addTrail(payload.ref, { + color: payload.color, width: payload.width, + lifetime: payload.lifetime, + }); + } + if (id == null) { + this._log('error', 'не удалось создать ' + payload.kind); + } else if (payload.localRef) { + if (!this._fxLocalToReal) this._fxLocalToReal = new Map(); + this._fxLocalToReal.set(payload.localRef, id); + } + } + return; + } + if (cmd === 'fx.beamColor') { + const fid = this._resolveFxId(payload?.ref); + if (fid != null) this.scene3d?.beamManager?.setBeamColor(fid, payload?.color); + return; + } + // === Задача 08: стрелка-указатель + расширенное управление лучом === + if (cmd === 'fx.createPointer') { + const bm = this.scene3d?.beamManager; + if (bm && payload) { + const id = bm.addPointer({ + from: payload.from, to: payload.to, preset: payload.preset, + color: payload.color, texture: payload.texture, + customTextureUrl: payload.customTextureUrl, + textureSpeed: payload.textureSpeed, width: payload.width, + strokeColor: payload.strokeColor, colorSequence: payload.colorSequence, + curved: payload.curved, curveHeight: payload.curveHeight, + faceMode: payload.faceMode, attachOffset: payload.attachOffset, + }); + if (id == null) { + this._log('error', 'не удалось создать стрелку-указатель'); + } else if (payload.localRef) { + if (!this._fxLocalToReal) this._fxLocalToReal = new Map(); + this._fxLocalToReal.set(payload.localRef, id); + } + } + return; + } + if (cmd === 'fx.pointerTarget') { + const fid = this._resolveFxId(payload?.ref); + if (fid != null) this.scene3d?.beamManager?.setPointerTarget(fid, payload?.to); + return; + } + if (cmd === 'fx.pointerUpdate') { + const fid = this._resolveFxId(payload?.ref); + const bm = this.scene3d?.beamManager; + if (fid != null && bm) { + const o = payload?.opts || {}; + if (o.preset) bm.applyPointerPreset(fid, o.preset); + bm.updateBeam(fid, o); + } + return; + } + if (cmd === 'fx.beamUpdate') { + const fid = this._resolveFxId(payload?.ref); + if (fid != null) this.scene3d?.beamManager?.updateBeam(fid, payload?.opts || {}); + return; + } + if (cmd === 'fx.beamVisible') { + const fid = this._resolveFxId(payload?.ref); + if (fid != null) this.scene3d?.beamManager?.setVisible(fid, payload?.visible !== false); + return; + } + if (cmd === 'fx.beamEndpoints') { + const fid = this._resolveFxId(payload?.ref); + if (fid != null) { + this.scene3d?.beamManager?.setBeamEndpoints( + fid, payload?.from, payload?.to); + } + return; + } + if (cmd === 'fx.remove') { + const fid = this._resolveFxId(payload?.ref); + if (fid != null) this.scene3d?.beamManager?.remove(fid); + return; + } + // === Звук — game.sound.* (Фаза 5.5) === + // Пользовательский звук из библиотеки проекта (Фаза 5.5). + // Встроенные пресеты ({name} без soundId) обрабатывает старый + // обработчик ниже — здесь только {soundId}. + if (cmd === 'sound.play' && payload && typeof payload.soundId === 'string') { + const sm = this.scene3d?.soundManager; + if (sm && this.scene3d?.soundLibrary?.count() > 0) { + // attachRef может быть локальным ref от scene.spawn — резолвим. + let attachRef = payload.attachRef; + if (typeof attachRef === 'string' && attachRef !== 'player' + && this._localToReal?.has(attachRef)) { + attachRef = this._localToReal.get(attachRef); + } + const instId = sm.play(payload.soundId, { + volume: payload.volume, + loop: payload.loop, + at: payload.at, + attachRef, + }); + if (instId != null && payload.localRef) { + if (!this._soundLocalToReal) this._soundLocalToReal = new Map(); + this._soundLocalToReal.set(payload.localRef, instId); + } + } + return; + } + if (cmd === 'sound.stop') { + const ref = payload?.ref; + if (ref != null && this.scene3d?.soundManager) { + const instId = this._soundLocalToReal?.has(ref) + ? this._soundLocalToReal.get(ref) : Number(ref); + if (Number.isFinite(instId)) { + this.scene3d.soundManager.stopSound(instId); + } + } + return; + } + // === Tool / инвентарь API (Фаза 4.2) === + if (cmd === 'inventory.give') { + // payload: { kind, modelTypeId, name, params } + const inv = this.scene3d?.inventory; + if (inv && payload) { + const idx = inv.add({ + kind: payload.kind || 'item', + modelTypeId: payload.modelTypeId || null, + name: payload.name || 'Предмет', + params: payload.params || {}, + }); + if (idx < 0) { + this._log('error', 'инвентарь полон — предмет не добавлен'); + } else if (payload.equip) { + // Сразу сделать активным и снарядить (для giveTool). + inv.setActive(idx); + const item = inv.slots[idx]; + if (item && item.kind === 'weapon' && this.scene3d?.weapons) { + try { this.scene3d.weapons.equip(item); } catch (e) {} + } + } + } + return; + } + if (cmd === 'inventory.remove') { + // payload: { modelTypeId? , name? } — убрать первый совпавший слот. + const inv = this.scene3d?.inventory; + if (inv && payload) { + const slots = inv.slots; + for (let i = 0; i < slots.length; i++) { + const s = slots[i]; + if (!s) continue; + const matchModel = payload.modelTypeId && s.modelTypeId === payload.modelTypeId; + const matchName = payload.name && s.name === payload.name; + if (matchModel || matchName) { + // Если убираем активное оружие — снять модель из руки. + if (i === inv.activeIndex && this.scene3d?.weapons) { + try { this.scene3d.weapons.unequip(); } catch (e) {} + } + inv.removeSlot(i); + break; + } + } + } + return; + } + if (cmd === 'inventory.clear') { + const inv = this.scene3d?.inventory; + if (inv) { + try { this.scene3d?.weapons?.unequip(); } catch (e) {} + inv.clear(); + } + return; + } + // === Мультиплеер-API: общее состояние комнаты (Фаза 4.3) === + if (cmd === 'room.set') { + // payload: { key, value } + if (payload && typeof payload.key === 'string') { + if (!this._roomState) this._roomState = {}; + const changed = this._roomState[payload.key] !== payload.value; + this._roomState[payload.key] = payload.value; + // Если есть Colyseus-комната — отправляем серверу (он + // обновит общее state; серверная схема — отдельная задача). + try { + this.scene3d?._mpSync?.room?.send?.('scriptRoomSet', { + key: payload.key, value: payload.value, + }); + } catch (e) { /* ignore */ } + // Локально сразу рассылаем событие изменения всем скриптам. + if (changed) { + this.routeGlobalEvent('roomChange', { + key: payload.key, value: payload.value, + }); + } + } + return; + } + if (cmd === 'mp.sendTo') { + // payload: { sessionId, name, data } — адресное сообщение игроку. + if (payload) { + const mp = this.scene3d?._mpSync; + if (mp && mp.room && typeof mp.room.send === 'function') { + // С комнатой — через сервер (релей по sessionId). + try { + mp.room.send('scriptMessage', { + to: payload.sessionId, + name: payload.name, + data: payload.data, + }); + } catch (e) { /* ignore */ } + } else if (payload.sessionId === 'local') { + // Single-player: сообщение «себе» — доставляем сразу. + this.routeGlobalEvent('mpMessage', { + from: 'local', name: payload.name, data: payload.data, + }); + } + } + return; + } + // === Команды / Teams (Фаза 4.4) === + if (cmd === 'teams.create') { + // payload: { name, color } + if (payload && typeof payload.name === 'string' && payload.name) { + if (!this._teams) this._teams = new Map(); + this._teams.set(payload.name, { + name: payload.name, + color: typeof payload.color === 'string' ? payload.color : '#888888', + }); + } + return; + } + if (cmd === 'teams.remove') { + if (payload && this._teams) { + this._teams.delete(payload.name); + // Если игрок был в этой команде — сбрасываем. + if (this._localPlayerTeam === payload.name) { + this._localPlayerTeam = null; + } + } + return; + } + if (cmd === 'player.setTeam') { + // payload: { team } — null/'' убирает команду. + const t = payload?.team; + let applied = null; + if (t == null || t === '') { + this._localPlayerTeam = null; + applied = ''; + } else if (typeof t === 'string') { + // Назначаем только если команда существует. + if (this._teams?.has(t)) { + this._localPlayerTeam = t; + applied = t; + } else { + this._log('error', 'команда не создана: ' + t); + } + } + // С Colyseus-комнатой — синхронизируем команду на сервер, + // чтобы остальные игроки видели её в Player.team. + if (applied != null) { + try { + this.scene3d?._mpSync?.room?.send?.('setTeam', { team: applied }); + } catch (e) { /* ignore */ } + } + return; + } + if (cmd === 'player.setSpeed') { + const player = this.scene3d?.player; + if (player) { + const m = Number(payload?.mul); + if (Number.isFinite(m) && m > 0) player._speedMul = m; + } + return; + } + // Подфаза 3.11 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md: + // game.player.equipAccessory(itemId) — надеть аксессуар прямо + // из скрипта игры (например выдать всем хеллоуинскую шапку при + // спавне). itemId — числовой id из rublox_items. + // Бэк фильтрует только published — на сервере ничего не настроишь. + if (cmd === 'player.equipAccessory') { + const player = this.scene3d?.player; + const itemId = Number(payload?.itemId); + if (!player || !Number.isFinite(itemId) || itemId <= 0) return; + (async () => { + try { + // Грузим item через публичный catalog (только published) + const resp = await fetch(`/api-storys/rublox/catalog/${itemId}`); + if (!resp.ok) return; + const item = await resp.json(); + if (item && typeof player.equipAccessory === 'function') { + await player.equipAccessory(item); + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] equipAccessory failed', e); + } + })(); + return; + } + if (cmd === 'player.unequipSlot') { + const player = this.scene3d?.player; + const slot = String(payload?.slot || ''); + if (player && slot && typeof player.unequipSlot === 'function') { + player.unequipSlot(slot); + } + return; + } + if (cmd === 'player.unequipAll') { + const player = this.scene3d?.player; + if (player && typeof player.unequipAll === 'function') { + player.unequipAll(); + } + return; + } + if (cmd === 'player.setJumpPower') { + const player = this.scene3d?.player; + if (player) { + const m = Number(payload?.mul); + if (Number.isFinite(m) && m > 0) player._jumpPowerMul = m; + } + return; + } + if (cmd === 'player.setGravityMul') { + // Множитель гравитации (для GD-стиля нужно ~1.23 — поднимает 22 до 27). + // Не зависит от gravityDir — работает в обоих направлениях. + const player = this.scene3d?.player; + if (player) { + const m = Number(payload?.mul); + if (Number.isFinite(m) && m > 0) player._gravityMul = m; + } + return; + } + if (cmd === 'player.setShipMode') { + // GD-гейммод Ship: тап-удержание = подъём (вертолёт-стиль). + const player = this.scene3d?.player; + if (player) player._shipMode = !!payload?.enabled; + return; + } + if (cmd === 'player.setUfoMode') { + // GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе. + const player = this.scene3d?.player; + if (player) player._ufoMode = !!payload?.enabled; + return; + } + if (cmd === 'player.setWaveMode') { + // GD-гейммод Wave: движение под ±45° (Space зажат — вверх, отпущен — вниз). + const player = this.scene3d?.player; + if (player) player._waveMode = !!payload?.enabled; + return; + } + if (cmd === 'player.setVy') { + // Прямое задание vy (для трамплинов, jump orb, boost-зон). + const player = this.scene3d?.player; + if (player) { + const v = Number(payload?.vy); + if (Number.isFinite(v)) player._vy = v; + } + return; + } + if (cmd === 'player.setRobotMode') { + // GD-гейммод Robot: variable-jump (высота = длительности удержания Space). + const player = this.scene3d?.player; + if (player) { + player._robotMode = !!payload?.enabled; + if (!player._robotMode) player._robotBoostLeft = 0; + } + return; + } + if (cmd === 'player.setDoubleJump') { + const player = this.scene3d?.player; + if (player) player._doubleJumpEnabled = !!payload?.enabled; + return; + } + if (cmd === 'player.playAnimation') { + const player = this.scene3d?.player; + if (player && typeof player.playEmote === 'function') { + const ok = player.playEmote(payload?.name); + if (!ok) { + this._log('error', 'playAnimation: эмоция не найдена — ' + + payload?.name + ' (доступно: wave, dance, cheer, sit)'); + } + } + return; + } + if (cmd === 'player.stopAnimation') { + const player = this.scene3d?.player; + if (player && typeof player.stopEmote === 'function') player.stopEmote(); + return; + } + if (cmd === 'player.setIceFriction') { + const player = this.scene3d?.player; + if (player) { + const v = Number(payload?.value); + if (Number.isFinite(v)) { + player._iceFriction = Math.max(0, Math.min(1, v)); + } + } + return; + } + if (cmd === 'player.setAutoRun') { + const player = this.scene3d?.player; + if (player) { + const s = Number(payload?.speed); + if (Number.isFinite(s)) player._autoRunSpeed = Math.max(0, s); + } + return; + } + if (cmd === 'player.boostJump') { + const player = this.scene3d?.player; + if (player) { + const s = Number(payload?.strength); + if (Number.isFinite(s) && s > 0) { + // boostJump учитывает текущую гравитацию: при flipped — толкает к потолку (vy<0) + const gDir = player._gravityDir || 1; + const base = player.JUMP_VELOCITY * (player._jumpPowerMul || 1); + player._vy = base * s * gDir; + } + } + return; + } + if (cmd === 'player.flipGravity') { + // Меняет направление гравитации (как blue orb в GD): +1 ↔ -1 + const player = this.scene3d?.player; + if (player) { + player._gravityDir = (player._gravityDir || 1) > 0 ? -1 : 1; + // Сбрасываем "second jump used" чтобы после флипа доступен прыжок + player._doubleJumpUsed = false; + } + return; + } + if (cmd === 'player.setGravityDir') { + // Явно задать направление: dir=1 (вниз) или -1 (вверх). + const player = this.scene3d?.player; + if (player) { + const d = Number(payload?.dir); + if (d === 1 || d === -1) { + player._gravityDir = d; + player._doubleJumpUsed = false; + } + } + return; + } + if (cmd === 'player.getGravityDir') { + // Возвращает текущее значение через broadcast-style "reply" + // Скрипту это нужно через геттер game.player.gravityDir — см. shim в Worker + return; + } + // === HUD / Input / App === + if (cmd === 'hud.setVisible') { + try { + const v = !!payload?.visible; + this.scene3d?._setStdHudVisible?.(v); + } catch (e) {} + return; + } + if (cmd === 'hud.setHotbarVisible') { + try { this.scene3d?._setHotbarVisible?.(!!payload?.visible); } catch (e) {} + return; + } + if (cmd === 'hud.setHpVisible') { + try { this.scene3d?._setHpVisible?.(!!payload?.visible); } catch (e) {} + return; + } + if (cmd === 'input.setCursorMode') { + try { + const mode = payload?.mode === 'ui' ? 'ui' : 'game'; + const player = this.scene3d?.player; + if (player?.setUiCursorMode) { + player.setUiCursorMode(mode === 'ui'); + if (mode === 'ui') { + try { document.exitPointerLock?.(); } catch (e) {} + // Подписываемся на mouse-события и транслируем в Worker. + if (player.setUiMouseMoveCallback) { + let lastMM = 0; + player.setUiMouseMoveCallback((x, y) => { + const now = performance.now(); + if (now - lastMM < 20) return; + lastMM = now; + this.routeGlobalEvent('mouseMove', { x, y }); + }); + } + if (player.setUiMouseDownCallback) { + player.setUiMouseDownCallback((x, y) => { + this.routeGlobalEvent('mouseDown', { x, y }); + }); + } + if (player.setUiMouseUpCallback) { + player.setUiMouseUpCallback((x, y) => { + this.routeGlobalEvent('mouseUp', { x, y }); + }); + } + } else if (player._requestPointerLockSafe) { + // Отписываемся при возврате в game-режим + if (player.setUiMouseMoveCallback) { + player.setUiMouseMoveCallback(null); + } + if (player.setUiMouseDownCallback) { + player.setUiMouseDownCallback(null); + } + if (player.setUiMouseUpCallback) { + player.setUiMouseUpCallback(null); + } + try { player._requestPointerLockSafe(); } catch (e) {} + } + // Сообщить редактору/плееру чтобы синхронизировать UI-state + try { this.scene3d?._onCursorModeChange?.(mode); } catch (e) {} + } + } catch (e) {} + return; + } + if (cmd === 'app.exit') { + try { + // На Майнкрафтия-плеере это шло на свой роут /kubikon3d + // (лента игр). В выделенном плеере (player.rublox.pro) + // таких роутов нет — переходим на ленту Рублокса. + window.location.assign(this._resolveExternalUrl('/kubikon3d')); + } catch (e) {} + return; + } + if (cmd === 'app.navigate') { + try { + const url = String(payload?.url || ''); + if (url) window.location.assign(this._resolveExternalUrl(url)); + } catch (e) {} + return; + } + // === Универсальное хранилище сейвов (game.save.*) === + if (cmd === 'save.get') { + this._saveGet(scriptId, payload); + return; + } + if (cmd === 'save.getAll') { + this._saveGetAll(scriptId, payload); + return; + } + if (cmd === 'save.set') { + this._saveSet(payload); + return; + } + if (cmd === 'save.merge') { + this._saveMerge(payload); + return; + } + if (cmd === 'save.leaderboard') { + this._saveLeaderboard(scriptId, payload); + return; + } + if (cmd === 'economy.reward') { + this._economyReward(scriptId, payload); + return; + } + if (cmd === 'economy.dailyCheck') { + this._economyDailyCheck(scriptId, payload); + return; + } + if (cmd === 'economy.getBalance') { + this._economyGetBalance(scriptId, payload); + return; + } + if (cmd === 'economy.spend') { + this._economySpend(scriptId, payload); + return; + } + if (cmd === 'camera.shake') { + const player = this.scene3d?.player; + if (player) { + const amp = Number(payload?.amp); + const dur = Number(payload?.dur); + if (Number.isFinite(amp) && Number.isFinite(dur) && amp > 0 && dur > 0) { + player._cameraShakeAmp = amp; + player._cameraShakeLeft = dur; + } + } + return; + } + // === Камера: FOV, привязка, катсцены (Фаза 5.7) === + if (cmd === 'camera.fov') { + this.scene3d?.player?.setCameraFov?.(payload?.degrees); + return; + } + if (cmd === 'camera.focus') { + // payload: { ref, distance, height } — следить за объектом. + const player = this.scene3d?.player; + if (player && payload && typeof payload.ref === 'string') { + const ref = payload.ref; + // getTarget резолвит позицию объекта каждый кадр. + const getTarget = () => { + const tgt = this._resolveTweenTarget(ref); + if (tgt && tgt.data) { + return { x: tgt.data.x, y: tgt.data.y, z: tgt.data.z }; + } + return null; + }; + player.cameraFocusOn(getTarget, { + distance: payload.distance, height: payload.height, + }); + } + return; + } + if (cmd === 'camera.cutscene') { + // payload: { points: [{x,y,z}], lookAt: [{x,y,z}], segDuration } + const player = this.scene3d?.player; + if (player && payload && Array.isArray(payload.points)) { + player.cameraCutscene( + payload.points, payload.lookAt, payload.segDuration, + // onDone — событие скрипту. + () => this.routeGlobalEvent('cutsceneDone', {}), + ); + } + return; + } + if (cmd === 'camera.reset') { + this.scene3d?.player?.cameraReset?.(); + return; + } + if (cmd === 'player.setSkinVisible') { + const player = this.scene3d?.player; + if (player) { + const v = !!payload?.visible; + player._skinVisibleScripted = v; + // Применяем сразу — но также флаг будет применяться каждый + // кадр в _tick (на случай если меши ещё не загружены сейчас). + if (Array.isArray(player._modelMeshes)) { + for (const m of player._modelMeshes) { + try { m.setEnabled(v); } catch (e) {} + } + } + } + return; + } + if (cmd === 'player.setCameraMode') { + const player = this.scene3d?.player; + if (player && typeof payload?.mode === 'string') { + const valid = ['first', 'third', 'front', 'sideview']; + if (valid.includes(payload.mode)) { + player._cameraMode = payload.mode; + try { player._applyCameraMode?.(); } catch (e) {} + } + } + return; + } + if (cmd === 'player.setCrouch') { + const player = this.scene3d?.player; + if (player) { + const want = !!payload?.enabled; + player._scriptForcedCrouch = want; + if (want !== player._crouching) { + player._crouching = want; + const newHalfH = want ? player.HALF_H_CROUCH : player.HALF_H_NORMAL; + // КРИТИЧНО: _pos — центр капсулы. При смене HALF_H + // центр надо сдвинуть на ту же дельту, иначе «низ ног» + // (_pos.y - HALF_H) меняется и персонажа подкидывает + // вверх при приседе. Сдвигаем — низ ног остаётся на месте. + const dH = newHalfH - player.HALF_H; + player.HALF_H = newHalfH; + if (player._pos) player._pos.y += dH; + } + } + return; + } + if (cmd === 'player.setFacing') { + // Развернуть модель игрока на угол yaw (радианы). Полезно + // в кат-сценах, когда игрок стоит лицом куда нужно. + const player = this.scene3d?.player; + if (player) { + const yaw = Number(payload?.yaw); + if (Number.isFinite(yaw)) { + player._modelYaw = yaw; + if (player._modelRoot) player._modelRoot.rotation.y = yaw; + } + } + return; + } + if (cmd === 'player.emote') { + // Проиграть эмоцию персонажа (wave/dance/cheer/sit/paint). + // Работает только для R15-скинов. + const player = this.scene3d?.player; + if (player && typeof player.playEmote === 'function') { + const name = payload?.name; + if (typeof name === 'string') { + try { player.playEmote(name); } catch (e) { /* ignore */ } + } + } + return; + } + if (cmd === 'player.stopEmote') { + const player = this.scene3d?.player; + if (player && typeof player.stopEmote === 'function') { + try { player.stopEmote(); } catch (e) { /* ignore */ } + } + return; + } + if (cmd === 'timer.start' || cmd === 'timer.stop' || cmd === 'timer.submit') { + // Делегируем в scene3d — у него есть колбэки для UI/API + const fn = this.scene3d?.[cmd === 'timer.start' ? '_timerStart' + : cmd === 'timer.stop' ? '_timerStop' : '_timerSubmit']; + if (typeof fn === 'function') { + try { fn.call(this.scene3d); } catch (e) { /* ignore */ } + } + return; + } + if (cmd === 'self.move') { + this._applySelfMove(payload); + return; + } + if (cmd === 'scene.rotate') { + try { + const ry = Number(payload?.rotationY); + if (!Number.isFinite(ry)) return; + const pm = this.scene3d?.primitiveManager; + if (!pm) return; + const rid = this._resolvePrimitiveId(payload?.id); + const data = rid != null ? pm.instances.get(rid) : null; + if (data) { + data.rotationY = ry; + if (data.mesh?.rotation) { + data.mesh.rotation.y = ry; + if (data._worldMatrixFrozen) { + try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} + data._worldMatrixFrozen = false; + } + } + } + // snapshot не шлём — объект всё ещё findOne'ится по тому же ref'у, + // только rotationY обновился, для скрипта это прозрачно. + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] scene.rotate failed', e); + } + return; + } + if (cmd === 'scene.setRotation') { + try { + const rx = Number(payload?.rx); + const ry = Number(payload?.ry); + const rz = Number(payload?.rz); + if (!Number.isFinite(rx) || !Number.isFinite(ry) || !Number.isFinite(rz)) return; + const pm = this.scene3d?.primitiveManager; + if (!pm) return; + const rid = this._resolvePrimitiveId(payload?.id); + const data = rid != null ? pm.instances.get(rid) : null; + if (data) { + data.rotationX = rx; + data.rotationY = ry; + data.rotationZ = rz; + if (data.mesh?.rotation) { + data.mesh.rotation.x = rx; + data.mesh.rotation.y = ry; + data.mesh.rotation.z = rz; + if (data._worldMatrixFrozen) { + try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} + data._worldMatrixFrozen = false; + } + } + } + } catch (e) { + console.warn('[GameRuntime] scene.setRotation failed', e); + } + return; + } + if (cmd === 'scene.setCollide') { + try { + const canCollide = !!payload?.canCollide; + const pm = this.scene3d?.primitiveManager; + if (!pm) return; + const rid = this._resolvePrimitiveId(payload?.id); + const data = rid != null ? pm.instances.get(rid) : null; + if (data) { + data.canCollide = canCollide; + if (data.mesh?.metadata) data.mesh.metadata.canCollide = canCollide; + this.scene3d?.physics?.setSpatialDirty?.(); + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] scene.setCollide failed', e); + } + return; + } + if (cmd === 'scene.setColor') { + try { + const color = payload?.color; + if (typeof color !== 'string') return; + // Окрашиваемый блок (studs-block): ref 'block:x,y,z' → BlockManager. + const ref = payload?.id; + if (typeof ref === 'string' && ref.startsWith('block:')) { + const parts = ref.slice(6).split(',').map(Number); + if (parts.length === 3 && parts.every(Number.isFinite)) { + this.scene3d?.blockManager?.setBlockColor?.(parts[0], parts[1], parts[2], color); + } + return; + } + const pm = this.scene3d?.primitiveManager; + if (!pm) return; + const rid = this._resolvePrimitiveId(payload?.id); + const data = rid != null ? pm.instances.get(rid) : null; + if (data) { + data.color = color; + if (data.mesh?.material) { + const c = Color3.FromHexString(color); + data.mesh.material.diffuseColor = c; + // Если материал neon — обновляем emissive тоже + if (data.material === 'neon') { + data.mesh.material.emissiveColor = c; + } + if (data.material === 'studs') { + data.mesh.material.emissiveColor = new Color3(c.r * 0.45, c.g * 0.45, c.b * 0.45); + } + } + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] scene.setColor failed', e); + } + return; + } + if (cmd === 'scene.setOpacity') { + try { + const id = this._resolvePrimitiveId(payload?.ref); + const pm = this.scene3d?.primitiveManager; + if (id != null && pm) pm.updateInstance(id, { opacity: payload.opacity }); + } catch (e) { + console.warn('[GameRuntime] scene.setOpacity failed', e); + } + return; + } + if (cmd === 'scene.setScale') { + try { + const id = this._resolvePrimitiveId(payload?.ref); + const pm = this.scene3d?.primitiveManager; + if (id != null && pm) { + pm.updateInstance(id, { sx: payload.sx, sy: payload.sy, sz: payload.sz }); + } + } catch (e) { + console.warn('[GameRuntime] scene.setScale failed', e); + } + return; + } + if (cmd === 'scene.setMaterial') { + try { + const id = this._resolvePrimitiveId(payload?.ref); + const pm = this.scene3d?.primitiveManager; + if (id != null && pm) pm.updateInstance(id, { material: payload.material }); + } catch (e) { + console.warn('[GameRuntime] scene.setMaterial failed', e); + } + return; + } + if (cmd === 'scene.clone') { + try { + const id = this._resolvePrimitiveId(payload?.ref); + const pm = this.scene3d?.primitiveManager; + if (id == null || !pm) return; + const src = pm.instances.get(id); + if (!src) return; + const newId = pm.addInstance(src.type, { + x: (src.x || 0) + (Number(payload.dx) || 0), + y: (src.y || 0) + (Number(payload.dy) || 0), + z: (src.z || 0) + (Number(payload.dz) || 0), + sx: src.sx, sy: src.sy, sz: src.sz, + color: src.color, material: src.material, + rotationY: src.rotationY, + }); + if (newId != null) { + if (!this._localToReal) this._localToReal = new Map(); + this._localToReal.set(payload.newRef, 'primitive:' + newId); + this.scheduleSceneSnapshot(); + } + } catch (e) { + console.warn('[GameRuntime] scene.clone failed', e); + } + return; + } + if (cmd === 'self.registerInteract') { + try { + const t = payload?.target; + if (!t) return; + // ref объекта-носителя скрипта + const ref = (t.kind && (t.ref ?? t.id) != null) + ? (t.kind + ':' + (t.ref ?? t.id)) : null; + if (!ref) return; + // не дублируем — один объект = одна запись + if (!this._interactables.some(it => it.ref === ref)) { + this._interactables.push({ + ref, + target: t, + text: payload.text || 'Взаимодействовать', + distance: Number(payload.distance) || 4, + key: payload.key || 'e', + }); + } + } catch (e) { + console.warn('[GameRuntime] self.registerInteract failed', e); + } + return; + } + if (cmd === 'scene.setLabel') { + try { + const ref = payload?.ref; + const text = payload?.text; + if (typeof ref !== 'string') return; + // ленивое создание менеджера меток + if (!this.scene3d._labelManager) { + const { LabelManager } = require('./LabelManager'); + this.scene3d._labelManager = new LabelManager(this.scene3d.scene); + } + const lm = this.scene3d._labelManager; + // резолвим меш объекта (примитив или модель) + const tgt = this._resolveTweenTarget(ref); + const mesh = tgt && (tgt.data.mesh || tgt.data.rootMesh || tgt.data.rootNode); + if (mesh) { + lm.setLabel(ref, mesh, text, payload?.opts || {}); + } + } catch (e) { + console.warn('[GameRuntime] scene.setLabel failed', e); + } + return; + } + if (cmd === 'scene.clearLabel') { + try { + const lm = this.scene3d?._labelManager; + if (lm && typeof payload?.ref === 'string') lm.clearLabel(payload.ref); + } catch (e) { + console.warn('[GameRuntime] scene.clearLabel failed', e); + } + return; + } + if (cmd === 'scene.setData') { + try { + const { ref, key, value } = payload || {}; + if (typeof ref !== 'string' || typeof key !== 'string') return; + if (!this._objectData[ref]) this._objectData[ref] = {}; + this._objectData[ref][key] = value; + this.scheduleDataSnapshot(); + } catch (e) { + console.warn('[GameRuntime] scene.setData failed', e); + } + return; + } + // === Теги объектов (Фаза 5.6) — game.scene.tag/untag/getTagged === + // Теги хранятся как массив в _objectData[ref].__tags — переиспользуем + // готовый канал dataSnapshot, отдельная синхронизация не нужна. + if (cmd === 'scene.tag' || cmd === 'scene.untag') { + try { + const { ref, tag } = payload || {}; + if (typeof ref !== 'string' || typeof tag !== 'string' || !tag) return; + if (!this._objectData[ref]) this._objectData[ref] = {}; + const cur = Array.isArray(this._objectData[ref].__tags) + ? this._objectData[ref].__tags : []; + this._objectData[ref].__tags = cmd === 'scene.tag' + ? (cur.includes(tag) ? cur : [...cur, tag]) + : cur.filter(t => t !== tag); + this.scheduleDataSnapshot(); + } catch (e) { + console.warn('[GameRuntime] scene.tag failed', e); + } + return; + } + // === Collision groups (Фаза 5.9) — проходимость объекта/группы === + // physics.passThrough — игрок проходит сквозь объект (объект виден). + // target: ref одного объекта ИЛИ тег (тогда применяется ко всей + // группе объектов с этим тегом — теги = collision groups). + if (cmd === 'physics.passThrough') { + try { + const { target, on } = payload || {}; + if (typeof target !== 'string' || !target) return; + const pm = this.scene3d?.primitiveManager; + if (!pm) return; + // canCollide = !on (passThrough=true → коллизия выключена). + const canCollide = !on; + // Собираем список ref: либо один объект, либо все с тегом. + let refs; + if (target.indexOf(':') >= 0) { + refs = [target]; // похоже на ref объекта + } else { + // Тег — все объекты с ним. + refs = []; + for (const r of Object.keys(this._objectData)) { + const bag = this._objectData[r]; + if (bag && Array.isArray(bag.__tags) && bag.__tags.includes(target)) { + refs.push(r); + } + } + } + for (const r of refs) { + const rid = this._resolvePrimitiveId(r); + if (rid != null) pm.updateInstance(rid, { canCollide }); + } + // Сбрасываем кэш spatial-grid физики — иначе grid до 50мс + // держит старое состояние, и при возврате твёрдости (on=false) + // UNSTUCK не видит стену, игрок застревает в ней. + this.scene3d?.physics?.invalidateSpatialGrid?.(); + } catch (e) { + console.warn('[GameRuntime] physics.passThrough failed', e); + } + return; + } + if (cmd === 'physics.setVelocity' || cmd === 'physics.applyImpulse') { + try { + const id = this._resolvePrimitiveId(payload?.ref); + const pm = this.scene3d?.primitiveManager; + const dm = this.scene3d?.dynamics; + if (id == null || !pm || !dm) return; + const data = pm.instances.get(id); + if (!data) return; + const isImpulse = cmd === 'physics.applyImpulse'; + const vx = isImpulse ? payload.ix : payload.vx; + const vy = isImpulse ? payload.iy : payload.vy; + const vz = isImpulse ? payload.iz : payload.vz; + const ok = dm.applyToInstance(data, vx, vy, vz, isImpulse ? 'impulse' : 'set'); + if (!ok) { + this._log('error', cmd + ': объект закреплён (anchored) — ' + + 'физика работает только для незакреплённых объектов'); + } + } catch (e) { + console.warn('[GameRuntime] ' + cmd + ' failed', e); + } + return; + } + if (cmd === 'physics.explode') { + try { + const { x, y, z, radius, damage, force } = payload || {}; + const r = Number(radius) || 3; + // визуальный эффект взрыва + this._handleCommand(scriptId, 'scene.particles', { + type: 'explosion', position: { x, y, z }, + duration: 1.2, count: 2, color: null, + }); + // урон игроку если в радиусе + const player = this.scene3d?.player; + if (player && Number(damage) > 0) { + const pp = player._pos || player.position; + if (pp) { + const dx = pp.x - x, dy = (pp.y || 0) - y, dz = pp.z - z; + if (dx*dx + dy*dy + dz*dz <= r*r) { + try { player.takeDamage(Number(damage), 'explosion'); } catch (e) {} + } + } + } + // убиваем мобов в радиусе + const zm = this.scene3d?.zombieManager; + if (zm && typeof zm.getMobsSnapshot === 'function') { + const mobs = zm.getMobsSnapshot(); + for (const m of mobs) { + const dx = m.x - x, dy = (m.y || 0) - y, dz = m.z - z; + if (dx*dx + dy*dy + dz*dz <= r*r) { + try { zm.killById(m.id); } catch (e) {} + } + } + } + } catch (e) { + console.warn('[GameRuntime] physics.explode failed', e); + } + return; + } + if (cmd === 'tween.start') { + this._startTween(scriptId, payload); + return; + } + if (cmd === 'tween.cancel') { + const tid = payload?.tweenId; + if (tid != null) { + const i = this._tweens.findIndex(t => t.tweenId === tid && t.scriptId === scriptId); + if (i >= 0) this._tweens.splice(i, 1); + } + return; + } + if (cmd === 'scene.setTexture') { + // Установить динамическую текстуру примитива из dataURL. + // Используется GD-скинами куба (canvas-фабрика в скрипте → dataURL → текстура). + try { + const dataUrl = payload?.dataUrl; + if (typeof dataUrl !== 'string') return; + const pm = this.scene3d?.primitiveManager; + if (!pm) return; + const rid = this._resolvePrimitiveId(payload?.id); + if (rid != null) pm.setTexture(rid, dataUrl); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] scene.setTexture failed', e); + } + return; + } + // === AUDIO: GD-музыка и SFX === + if (cmd === 'audio.playSfx') { + try { + const am = this.scene3d?.gameAudioManager; + if (am && payload?.name) am.playSfx(payload.name); + } catch (e) { + console.warn('[GameRuntime] audio.playSfx failed', e); + } + return; + } + if (cmd === 'audio.playMusic') { + try { + const am = this.scene3d?.gameAudioManager; + if (am && payload?.trackId) am.playMusic(payload.trackId); + } catch (e) { + console.warn('[GameRuntime] audio.playMusic failed', e); + } + return; + } + if (cmd === 'audio.stopMusic') { + try { + const am = this.scene3d?.gameAudioManager; + if (am) am.stopMusic(); + } catch (e) { + console.warn('[GameRuntime] audio.stopMusic failed', e); + } + return; + } + if (cmd === 'audio.setMuted') { + try { + const am = this.scene3d?.gameAudioManager; + if (am) am.setMuted(!!payload?.muted); + } catch (e) { + console.warn('[GameRuntime] audio.setMuted failed', e); + } + return; + } + if (cmd === 'scene.setVisible') { + try { + const kind = payload?.kind; + const id = payload?.id; + const visible = !!payload?.visible; + if (id == null) return; + if (kind === 'primitive') { + const pm = this.scene3d?.primitiveManager; + if (!pm) return; + const rid = this._resolvePrimitiveId(id); + const data = rid != null ? pm.instances.get(rid) : null; + if (data) { + data.visible = visible; + if (data.mesh) data.mesh.setEnabled(visible); + } + } else if (kind === 'model') { + const mm = this.scene3d?.modelManager; + if (!mm) return; + let data = mm.instances.get(id); + if (!data && typeof id === 'string') { + const n = Number(id); + if (Number.isFinite(n)) data = mm.instances.get(n); + } + if (data) { + data.visible = visible; + if (data.rootMesh) data.rootMesh.setEnabled(visible); + } + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] scene.setVisible failed', e); + } + return; + } + if (cmd === 'scene.setFolderYaw') { + try { + const fm = this.scene3d?.folderManager; + if (!fm) return; + const name = payload?.folderName; + const angle = Number(payload?.angle); + const pivot = payload?.pivot; + if (typeof name !== 'string' || !Number.isFinite(angle) || !pivot) return; + const folder = fm.findByName(name); + if (!folder) return; + fm.setFolderYawY(folder.id, angle, pivot); + this.scheduleSceneSnapshot(); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] scene.setFolderYaw failed', e); + } + return; + } + if (cmd === 'self.delete') { + this._applySelfDelete(payload); + return; + } + if (cmd === 'scene.spawn') { + this._applySceneSpawn(scriptId, payload); + return; + } + if (cmd === 'scene.delete') { + this._applySceneDelete(payload); + return; + } + if (cmd === 'ui.set' || cmd === 'ui.flash' || cmd === 'ui.clear') { + // Просто пробрасываем в onHud колбэк — UI на стороне React сам отрисует + if (this._onHud) { + try { this._onHud({ cmd, payload }); } catch (e) { /* ignore */ } + } + return; + } + if (cmd === 'sound.play') { + this._playSound(payload); + return; + } + if (cmd === 'scene.particles') { + this._spawnParticles(payload); + return; + } + if (cmd === 'mob.kill') { + try { + const id = Number(payload?.id); + if (Number.isFinite(id) && this.scene3d?.zombieManager) { + this.scene3d.zombieManager.killById(id); + } + } catch (e) { + this._log('error', 'mob.kill failed: ' + (e?.message || e)); + } + return; + } + if (cmd === 'gui.update') { + // payload: { id, patch } + try { + let id = payload?.id; + const patch = payload?.patch || {}; + if (typeof id !== 'string') return; + // Резолвим локальный ref (тот что вернул gui.create) → реальный id + if (this._guiLocalToReal?.has(id)) id = this._guiLocalToReal.get(id); + this.scene3d?.updateGuiElement?.(id, patch); + this.scheduleGuiSnapshot(); + } catch (e) { + this._log('error', 'gui.update failed: ' + (e?.message || e)); + } + return; + } + if (cmd === 'gui.create') { + try { + const type = payload?.type; + const opts = { ...(payload?.opts || {}) }; + const localRef = payload?.localRef; + if (typeof type !== 'string') return; + // Помечаем как созданный скриптом — чтобы НЕ попал в + // сериализацию проекта (иначе автосейв сохранит его в БД + // и после Stop он «вернётся» из сохранённого проекта). + opts._scriptCreated = true; + // Резолвим parentId если это локальный ref из предыдущего create + if (opts.parentId && this._guiLocalToReal?.has(opts.parentId)) { + opts.parentId = this._guiLocalToReal.get(opts.parentId); + } + const realId = this.scene3d?.createGuiElement?.(type, opts); + if (realId && localRef) { + if (!this._guiLocalToReal) this._guiLocalToReal = new Map(); + if (!this._guiRealToLocal) this._guiRealToLocal = new Map(); + this._guiLocalToReal.set(localRef, realId); + this._guiRealToLocal.set(realId, localRef); + } + this.scheduleGuiSnapshot(); + } catch (e) { + this._log('error', 'gui.create failed: ' + (e?.message || e)); + } + return; + } + if (cmd === 'gui.remove') { + try { + let id = payload?.id; + if (typeof id !== 'string') return; + const localId = id; + if (this._guiLocalToReal?.has(id)) id = this._guiLocalToReal.get(id); + this.scene3d?.removeGuiElement?.(id); + // Чистим mapping чтобы не утекало + if (this._guiLocalToReal?.has(localId)) this._guiLocalToReal.delete(localId); + this.scheduleGuiSnapshot(); + } catch (e) { + this._log('error', 'gui.remove failed: ' + (e?.message || e)); + } + return; + } + if (cmd === 'broadcast') { + // Рассылаем именованное сообщение всем sandbox'ам + this.routeGlobalEvent('message', { + name: String(payload?.name || ''), + data: payload?.data ?? null, + }); + return; + } + if (cmd === 'player.crosshair') { + const type = String(payload?.type || 'none').toLowerCase(); + try { this.scene3d?.setCrosshair?.(type); } catch (e) { /* ignore */ } + if (this._onCrosshair) { + try { this._onCrosshair(type); } catch (e) { /* ignore */ } + } + return; + } + // === Задача 07: скины игрока === + if (cmd === 'player.setSkin') { + const player = this.scene3d?.player; + const slug = payload?.slug; + if (player && typeof slug === 'string' && slug) { + const typeId = this._resolveSkinTypeId(slug); + // Помечаем доступным (setSkin неявно разблокирует). + this._ensureSkinState(); + this._skinState.unlocked.add(slug); + this._skinState.current = slug; + // Асинхронная перезагрузка модели; по завершении шлём skinChanged. + Promise.resolve(player.reloadSkin?.(typeId)).then(() => { + this.routeGlobalEvent?.('skinChanged', { slug }); + try { this.scene3d?._onPlayerSkinChanged?.(slug); } catch (e) {} + }).catch((e) => { + this._log('error', 'setSkin failed: ' + (e?.message || e)); + }); + } + return; + } + if (cmd === 'player.unlockSkin') { + const slug = payload?.slug; + if (typeof slug === 'string' && slug) { + this._ensureSkinState(); + this._skinState.unlocked.add(slug); + this.routeGlobalEvent?.('skinUnlocked', { slug }); + } + return; + } + if (cmd === 'player.openSkinShop') { + this._ensureSkinState(); + try { this.scene3d?._openSkinShop?.(); } catch (e) {} + return; + } + if (cmd === 'player.closeSkinShop') { + try { this.scene3d?._closeSkinShop?.(); } catch (e) {} + return; + } + if (cmd === 'player.setSkinCoins') { + this._ensureSkinState(); + const n = Number(payload?.amount); + if (Number.isFinite(n)) { + this._skinState.coins = Math.max(0, Math.floor(n)); + this._broadcastSkinsSnapshot(); + } + return; + } + // Покупка скина из встроенного магазина (намерение от React-оверлея + // или из скрипта). Списывает локальные рублики, разблокирует, надевает. + if (cmd === 'player.buySkin') { + this._ensureSkinState(); + const slug = payload?.slug; + const price = Number(payload?.price) || 0; + if (typeof slug !== 'string' || !slug) return; + const st = this._skinState; + const owned = st.unlocked.has(slug); + if (owned) { + // Уже куплен — просто надеть. + this._handleCommand(scriptId, 'player.setSkin', { slug }); + return; + } + if (st.coins < price) { + // Не хватает — сообщаем оверлею. + try { this.scene3d?._onSkinBuyResult?.({ slug, ok: false, reason: 'no_coins' }); } catch (e) {} + return; + } + st.coins -= price; + st.unlocked.add(slug); + this._handleCommand(scriptId, 'player.setSkin', { slug }); + this._broadcastSkinsSnapshot(); + try { this.scene3d?._onSkinBuyResult?.({ slug, ok: true }); } catch (e) {} + return; + } + // === Задача 02: setCameraZoom / setCameraZoomLimits / setShiftLock === + if (cmd === 'player.setCameraZoom') { + const player = this.scene3d?.player; + if (player && typeof player.setCameraZoom === 'function') { + try { player.setCameraZoom(payload?.distance); } catch (e) {} + } + return; + } + if (cmd === 'player.setCameraZoomLimits') { + const player = this.scene3d?.player; + if (player && typeof player.setCameraZoomLimits === 'function') { + try { player.setCameraZoomLimits(payload?.min, payload?.max); } catch (e) {} + } + return; + } + if (cmd === 'player.setShiftLock') { + const player = this.scene3d?.player; + if (player && typeof player.setShiftLock === 'function') { + try { player.setShiftLock(payload?.on); } catch (e) {} + } + return; + } + // === Задача 02: environment API === + if (cmd === 'environment.setSkyColor') { + try { + const hex = String(payload?.color || ''); + const scene = this.scene3d?.scene; + if (scene && hex) { + // Парсим #rrggbb → clearColor + const m = hex.match(/^#?([0-9a-f]{6})$/i); + if (m) { + const n = parseInt(m[1], 16); + const r = ((n >> 16) & 0xff) / 255; + const g = ((n >> 8) & 0xff) / 255; + const b = (n & 0xff) / 255; + if (scene.clearColor) { + scene.clearColor.r = r; + scene.clearColor.g = g; + scene.clearColor.b = b; + scene.clearColor.a = 1; + } + } + } + } catch (e) { + this._log('error', 'environment.setSkyColor failed: ' + (e?.message || e)); + } + return; + } + if (cmd === 'environment.setFog') { + try { + const env = this.scene3d?.environment; + if (env && typeof env.setFog === 'function') { + env.setFog(payload?.enabled, payload?.color, payload?.density); + } + } catch (e) {} + return; + } + if (cmd === 'environment.setTimeOfDay') { + try { + const env = this.scene3d?.environment; + if (env && typeof env.setTimeOfDay === 'function') { + env.setTimeOfDay(payload?.hours); + } + } catch (e) {} + return; + } + // === Задача 03: GUI tween === + if (cmd === 'gui.tween') { + try { + const guiId = payload?.id; + if (typeof guiId !== 'string' || !guiId) return; + const gm = this.scene3d?.guiManager; + if (!gm) return; + // Резолв localRef → realId если есть + let realId = guiId; + if (this._guiLocalToReal?.has(guiId)) realId = this._guiLocalToReal.get(guiId); + const el = gm.elements?.find(e => e.id === realId); + if (!el) return; + if (!this._guiTweens) this._guiTweens = []; + // Снимок начальных значений по тем ключам что есть в props + const props = payload.props || {}; + const propKeys = Object.keys(props); + // Перебивка: убрать существующие tween'ы на ТОТ ЖЕ id, + // которые анимируют ХОТЯ БЫ ОДИН из этих же ключей. + // Это поведение Roblox TweenService: новый tween на тот же ключ перебивает старый. + for (let j = this._guiTweens.length - 1; j >= 0; j--) { + const old = this._guiTweens[j]; + if (old.realId !== realId) continue; + const oldKeys = Object.keys(old.target); + const overlap = oldKeys.some(k => propKeys.includes(k)); + if (overlap) this._guiTweens.splice(j, 1); + } + const start = {}; + for (const k of propKeys) { + if (k in el) start[k] = el[k]; + else if (k === 'scaleX' || k === 'scaleY' || k === 'rotation') start[k] = el[k] || (k === 'rotation' ? 0 : 1); + } + this._guiTweens.push({ + tweenId: payload.tweenId, + scriptId, + realId, + start, target: { ...props }, + elapsed: 0, + duration: Math.max(0.001, Number(payload.duration) || 0.5), + delay: Math.max(0, Number(payload.delay) || 0), + easing: payload.easing || 'ease', + repeat: Number.isFinite(payload.repeat) ? payload.repeat : 0, + reverses: !!payload.reverses, + iter: 0, + dir: 1, // 1 = вперёд, -1 = обратно (для reverses) + }); + } catch (e) { + this._log('error', 'gui.tween failed: ' + (e?.message || e)); + } + return; + } + if (cmd === 'gui.cancelTween') { + const tid = payload?.tweenId; + if (tid != null && this._guiTweens) { + const i = this._guiTweens.findIndex(t => t.tweenId === tid); + if (i >= 0) this._guiTweens.splice(i, 1); + } + return; + } + // === Задача 04: модал-сцены === + if (cmd === 'modal.open') { + try { + const mm = this.scene3d?.modalManager; + if (!mm) return; + // Резолв spotlights: строки-id → ref-объекты {kind:'primitive', id} как обычно + const opts = { ...(payload?.opts || {}) }; + if (Array.isArray(opts.spotlights)) { + opts.spotlights = opts.spotlights.map(r => this._normRef ? this._normRef(r) : r); + } + if (opts.cameraOverride && opts.cameraOverride.target) { + opts.cameraOverride = { + ...opts.cameraOverride, + target: this._normRef ? this._normRef(opts.cameraOverride.target) : opts.cameraOverride.target, + }; + } + const modalId = mm.open(opts); + // Подписка чтобы автоматически слать tweenDone-стиль событий + // на конкретный скрипт (тот кто открыл) — для onClose. + if (!mm._runtimeBoundOnClose) { + mm._runtimeBoundOnClose = true; + mm.onClose((closedId) => { + // Шлём событие всем sandbox'ам — они роутят к зарегистрированным fn + this.routeGlobalEvent?.('modalClosed', { id: closedId }); + }); + } + // Ответ обратно в worker: фактический modalId (юзер мог вернуть из open) + const sb = this.sandboxes.find(s => s.scriptId === scriptId); + if (sb && payload?.replyId != null) { + sb.sendGlobalEvent({ type: 'modalOpened', replyId: payload.replyId, modalId }); + } + } catch (e) { + this._log('error', 'modal.open failed: ' + (e?.message || e)); + } + return; + } + if (cmd === 'modal.close') { + try { + const mm = this.scene3d?.modalManager; + mm?.close?.(payload?.modalId); + } catch (e) {} + return; + } + if (cmd === 'modal.update') { + try { + const mm = this.scene3d?.modalManager; + mm?.update?.(payload?.modalId, payload?.patch); + } catch (e) {} + return; + } + // === Задача 01: Billboard 3D-таблички (см. BillboardUiManager) === + if (cmd === 'billboard.set' || cmd === 'billboard.update' || cmd === 'billboard.onClick') { + // Резолв ref → primitiveId. + // Worker может прислать ref сразу после game.scene.spawn — до + // того как main spawn'нул примитив и обновил _localToReal. + // Откладываем команду до резолва. + let ref = payload?.ref; + if (typeof ref === 'string' && ref.includes('_local_') + && !this._localToReal?.has(ref)) { + this._pendingResolveQueue = this._pendingResolveQueue || []; + this._pendingResolveQueue.push({ cmd, payload, scriptId }); + return; + } + try { + if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref); + let id = null; + if (typeof ref === 'string' && ref.startsWith('primitive:')) { + id = Number(ref.slice('primitive:'.length)); + } else if (Number.isFinite(ref)) { + id = Number(ref); + } + if (!Number.isFinite(id) || id == null) return; + const data = this.scene3d?.primitiveManager?.instances?.get(id); + if (!data || data.type !== 'billboard') return; + const mgr = this.scene3d?.billboardUiManager; + if (!mgr) return; + + if (cmd === 'billboard.set') { + mgr.applyToMesh(data, { + template: payload.template || data.billboard?.template || 'shop-item', + face: payload.face || data.billboard?.face || 'camera', + content: payload.content || data.billboard?.content, + elements: payload.elements || data.billboard?.elements, + }); + this.scheduleSceneSnapshot?.(); + } else if (cmd === 'billboard.update') { + // 2 формы: с elementId (точечно) или без (patch content) + if (typeof payload.elementId === 'string') { + mgr.update(data, payload.elementId, payload.patch || {}); + } else { + mgr.update(data, payload.patch || {}); + } + this.scheduleSceneSnapshot?.(); + } else if (cmd === 'billboard.onClick') { + const buttonId = String(payload.buttonId || 'buy'); + const realRef = 'primitive:' + id; + mgr.onClick(data, buttonId, () => { + const sb = this.sandboxes.find(s => s.scriptId === scriptId); + if (sb && typeof sb.sendGlobalEvent === 'function') { + // billboardClick роутится в worker'е через globalEvent-ветку + // (см. ScriptSandboxWorker.js cmd === 'globalEvent'). + sb.sendGlobalEvent({ + type: 'billboardClick', + ref: realRef, + button: buttonId, + }); + } + }); + } + } catch (e) { + this._log('error', cmd + ' failed: ' + (e?.message || e)); + } + return; + } + // eslint-disable-next-line no-console + console.warn('[GameRuntime] unknown cmd', cmd); + } + + /** + * Создать объект из скрипта. + * payload: { kind: 'block'|'model'|'primitive', subType, x, y, z, ref, ... } + * После создания обновляем `_localToReal` мапу — локальный ref ↔ реальный id. + */ + _applySceneSpawn(scriptId, payload) { + if (!payload) return; + const { kind, subType, ref } = payload; + if (!this._localToReal) this._localToReal = new Map(); + try { + if (kind === 'block') { + // color — для окрашиваемых блоков (studs-block); иначе игнорируется. + this.scene3d?.blockManager?.addBlock(payload.x, payload.y, payload.z, subType, payload.color); + // Для блоков ref детерминированный, но запоминаем — чтобы при + // Stop удалить заспавненные скриптом блоки (см. stop()). + if (ref) this._localToReal.set(ref, ref); + this.scheduleSceneSnapshot(); + } else if (kind === 'model') { + // addInstance возвращает Promise (async из-за GLB) + const opts = payload; + const p = this.scene3d?.modelManager?.addInstance( + subType, opts.x, opts.y, opts.z, opts.rotationY || 0 + ); + Promise.resolve(p).then((instId) => { + if (instId == null) return; + if (opts.name) { + const data = this.scene3d?.modelManager?.instances?.get(instId); + if (data) data.name = opts.name; + } + this._localToReal.set(ref, 'model:' + instId); + this._notifySpawnResolved(ref, 'model:' + instId); + this._drainPendingResolveQueue?.(ref); + this.scheduleSceneSnapshot(); + }).catch((err) => { + this._log('error', 'spawn model failed: ' + (err?.message || err)); + }); + } else if (kind === 'userModel') { + // Пользовательская воксельная модель: subType = 'user:'. + // addInstance возвращает Promise. + const opts = payload; + const p = this.scene3d?.userModelManager?.addInstance( + subType, opts.x, opts.y, opts.z, opts.rotationY || 0, + (opts.scale && Number(opts.scale) > 0) ? { scale: Number(opts.scale) } : {}, + ); + Promise.resolve(p).then((instId) => { + if (instId == null) return; + if (opts.name) { + const data = this.scene3d?.userModelManager?.instances?.get(instId); + if (data) data.name = opts.name; + } + this._localToReal.set(ref, 'usermodel:' + instId); + this._notifySpawnResolved(ref, 'usermodel:' + instId); + this._drainPendingResolveQueue?.(ref); + this.scheduleSceneSnapshot(); + }).catch((err) => { + this._log('error', 'spawn user model failed: ' + (err?.message || err)); + }); + } else if (kind === 'primitive') { + const opts = payload; + const id = this.scene3d?.primitiveManager?.addInstance(subType, { + x: opts.x, y: opts.y, z: opts.z, + sx: opts.sx, sy: opts.sy, sz: opts.sz, + color: opts.color, material: opts.material, + rotationY: opts.rotationY, + name: opts.name, + brightness: opts.brightness, range: opts.range, + effect: opts.effect, + // textureAsset — картинка из ассетов проекта на грани. + ...(opts.textureAsset != null ? { textureAsset: opts.textureAsset } : {}), + // anchored:false → объект падает (физика unanchored). + // canCollide:false → проходимый (зона-триггер). + ...(opts.anchored != null ? { anchored: opts.anchored } : {}), + ...(opts.canCollide != null ? { canCollide: opts.canCollide } : {}), + ...(opts.visible != null ? { visible: opts.visible } : {}), + }); + if (id != null) { + this._localToReal.set(ref, 'primitive:' + id); + this._notifySpawnResolved(ref, 'primitive:' + id); + this._drainPendingResolveQueue?.(ref); + const data = this.scene3d?.primitiveManager?.instances?.get(id); + if (data) { + // Помечаем как заспавненный скриптом — движок шлёт + // для таких onPlayerTouch (нужно для «поймай объект»). + data._scriptSpawned = true; + // Если unanchored — регистрируем в физике на лету, + // иначе он не падает (start() уже отработал). + if (opts.anchored === false) { + this.scene3d?.dynamics?.registerPrimitive(data); + } + } + this.scheduleSceneSnapshot(); + } + } + } catch (e) { + this._log('error', 'scene.spawn failed: ' + (e?.message || e)); + } + } + + /** Удалить объект по ref (поддерживает локальный ref от spawn и реальный). */ + _applySceneDelete(payload) { + if (!payload?.ref) return; + let ref = payload.ref; + // Резолвим локальный ref → реальный + if (this._localToReal?.has(ref)) ref = this._localToReal.get(ref); + // Ref всё ещё локальный ('_local_') — модель ещё не зарезолвилась + // (асинхронная загрузка GLB). Откладываем удаление: оно сработает + // в _notifySpawnResolved, когда реальный id появится. Без этого + // removeInstance(NaN) промахивался и объект «осиротевал» на сцене. + if (ref.indexOf('_local_') >= 0) { + if (!this._pendingDeletes) this._pendingDeletes = new Set(); + this._pendingDeletes.add(ref); + return; + } + try { + const colon = ref.indexOf(':'); + if (colon < 0) return; + const kind = ref.slice(0, colon); + const rest = ref.slice(colon + 1); + if (kind === 'block') { + const [xs, ys, zs] = rest.split(','); + this.scene3d?.blockManager?.removeBlock(Number(xs), Number(ys), Number(zs)); + } else if (kind === 'model') { + this.scene3d?.modelManager?.removeInstance(Number(rest)); + } else if (kind === 'primitive') { + this.scene3d?.primitiveManager?.removeInstance(Number(rest)); + } + // Удалили — снимаем mapping + for (const [k, v] of (this._localToReal || new Map()).entries()) { + if (v === ref) this._localToReal.delete(k); + } + this.scheduleSceneSnapshot(); + } catch (e) { + this._log('error', 'scene.delete failed: ' + (e?.message || e)); + } + } + + /** + * Запланировать рассылку sceneSnapshot всем sandbox'ам в следующем кадре. + * Делается отложенно чтобы при массовом spawn (например в onKey) отправить + * snapshot один раз, а не N раз. + */ + scheduleSceneSnapshot() { + if (this._snapshotPending) return; + this._snapshotPending = true; + // microtask — следующий кадр render-loop'а почти наверняка + Promise.resolve().then(() => { + this._snapshotPending = false; + this._broadcastSceneSnapshot(); + }); + } + + /** Рассылка snapshot всем sandbox'ам. */ + _broadcastSceneSnapshot() { + if (!this._isRunning || this.sandboxes.length === 0) return; + const snap = this._buildSceneSnapshot(); + for (const sb of this.sandboxes) { + sb.sendSceneSnapshot(snap); + } + } + + /** Запланировать рассылку GUI-snapshot всем sandbox'ам в следующем microtask. */ + scheduleGuiSnapshot() { + if (this._guiSnapshotPending) return; + this._guiSnapshotPending = true; + Promise.resolve().then(() => { + this._guiSnapshotPending = false; + this._broadcastGuiSnapshot(); + }); + } + + _broadcastGuiSnapshot() { + if (!this._isRunning || this.sandboxes.length === 0) return; + const snap = this._buildGuiSnapshot(); + for (const sb of this.sandboxes) { + sb.sendGuiSnapshot(snap); + } + } + + /** Запланировать рассылку snapshot атрибутов объектов (game.scene.setData). */ + scheduleDataSnapshot() { + if (this._dataSnapshotPending) return; + this._dataSnapshotPending = true; + Promise.resolve().then(() => { + this._dataSnapshotPending = false; + this._broadcastDataSnapshot(); + }); + } + + _broadcastDataSnapshot() { + if (!this._isRunning || this.sandboxes.length === 0) return; + for (const sb of this.sandboxes) { + sb.sendDataSnapshot(this._objectData); + } + } + + _buildGuiSnapshot() { + const list = this.scene3d?.getGuiElements?.() || []; + return list.map(g => ({ + id: g.id, type: g.type, name: g.name, + parentId: g.parentId || null, + x: g.x, y: g.y, w: g.w, h: g.h, anchor: g.anchor, + visible: g.visible !== false, + text: g.text, textColor: g.textColor, textSize: g.textSize, + bgColor: g.bgColor, bgOpacity: g.bgOpacity, + imageUrl: g.imageUrl, + placeholder: g.placeholder, + })); + } + + /** Собрать snapshot сцены для синхронных game.scene.find/all/getPosition в Worker'ах. */ + _buildSceneSnapshot() { + const blocks = []; + const models = []; + const primitives = []; + const s = this.scene3d; + if (s?.blockManager) { + for (const proxy of s.blockManager.blocks.values()) { + const md = proxy.metadata; + if (!md?.isBlock) continue; + blocks.push({ + ref: 'block:' + md.gridX + ',' + md.gridY + ',' + md.gridZ, + type: md.blockTypeId, + x: md.gridX, y: md.gridY, z: md.gridZ, + }); + } + } + if (s?.modelManager) { + for (const data of s.modelManager.instances.values()) { + models.push({ + ref: 'model:' + data.instanceId, + type: data.modelTypeId, + x: data.x, y: data.y, z: data.z, + name: data.name || null, + }); + } + } + if (s?.primitiveManager) { + for (const data of s.primitiveManager.instances.values()) { + primitives.push({ + ref: 'primitive:' + data.id, + type: data.type, + x: data.x, y: data.y, z: data.z, + // размеры/поворот нужны для game.physics.raycast (ray vs AABB) + sx: data.sx != null ? data.sx : 1, + sy: data.sy != null ? data.sy : 1, + sz: data.sz != null ? data.sz : 1, + rotationY: data.rotationY || 0, + visible: data.visible !== false, + name: data.name || null, + }); + } + } + return { blocks, models, primitives }; + } + + _applySelfMove(payload) { + if (!payload || !payload.target) return; + const t = payload.target; + const { x, y, z } = payload; + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return; + try { + if (t.kind === 'model') { + let id = t.id ?? t.ref; + const mm = this.scene3d?.modelManager; + if (!mm) return; + // Локальный ref '_local_N' от scene.spawn → реальный id. + if (typeof id === 'string' && id.indexOf('_local_') === 0 + && this._localToReal) { + const real = this._localToReal.get('model:' + id); + if (real) { + const c2 = real.indexOf(':'); + id = c2 >= 0 ? real.slice(c2 + 1) : real; + } + } + let data = mm.instances.get(id); + if (!data && typeof id === 'string') { + const n = Number(id); + if (Number.isFinite(n)) data = mm.instances.get(n); + } + if (data) { + data.x = x; data.y = y; data.z = z; + if (data.rootMesh?.position) { + data.rootMesh.position.set(x, y, z); + if (data._worldMatrixFrozen) { + try { data.rootMesh.unfreezeWorldMatrix?.(); } catch (e) {} + if (Array.isArray(data.meshes)) { + for (const m of data.meshes) { + try { m?.unfreezeWorldMatrix?.(); } catch (e) {} + } + } + data._worldMatrixFrozen = false; + } + } + } + } else if (t.kind === 'primitive') { + const pm = this.scene3d?.primitiveManager; + if (!pm) return; + // _resolvePrimitiveId умеет и числовой id, и локальный + // ref '_local_N' (от scene.spawn) — без этого scene.move + // не находит объект, заспавненный скриптом. + const rid = this._resolvePrimitiveId(t.id ?? t.ref); + const data = rid != null ? pm.instances.get(rid) : null; + if (data) { + data.x = x; data.y = y; data.z = z; + if (data.mesh?.position) { + data.mesh.position.set(x, y, z); + if (data._worldMatrixFrozen) { + try { data.mesh.unfreezeWorldMatrix?.(); } catch (e) {} + data._worldMatrixFrozen = false; + } + } + } + } else if (t.kind === 'userModel') { + // userModel-инстанс: отдельная нода (rootNode), не thin-instance. + // Двигаем root.position + обновляем data.x/y/z. + const id = t.id ?? t.ref; + const um = this.scene3d?.userModelManager; + if (!um) return; + let data = um.instances.get(id); + if (!data && typeof id === 'string') { + const n = Number(id); + if (Number.isFinite(n)) data = um.instances.get(n); + } + if (data) { + data.x = x; data.y = y; data.z = z; + if (data.rootNode?.position) { + data.rootNode.position.set(x, y, z); + } + } + } + // НЕ шлём sceneSnapshot при move — позиция объекта в snapshot всё + // равно стейл (sandbox использует findOne и сам не зависит от + // координат в snapshot). Иначе при анимации платформ (десятки + // scene.move в секунду) шлём весь snapshot 11000+ объектов в worker + // через структурный postMessage — это может стоить сотни мс. + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] self.move failed', e); + } + } + + _applySelfDelete(payload) { + if (!payload || !payload.target) return; + const t = payload.target; + try { + if (t.kind === 'block') { + const r = t.ref || t; + this.scene3d?.blockManager?.removeBlock(r.x, r.y, r.z); + } else if (t.kind === 'model') { + const id = t.id ?? t.ref; + this.scene3d?.modelManager?.removeInstance(id); + } else if (t.kind === 'primitive') { + const id = t.id ?? t.ref; + this.scene3d?.primitiveManager?.removeInstance(id); + } + this.scheduleSceneSnapshot(); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[GameRuntime] self.delete failed', e); + } + } + + _log(level, text) { + if (this._onLog) { + try { this._onLog({ level, text, ts: Date.now() }); } catch (e) { /* ignore */ } + } + } + + /** + * Воспроизвести встроенный звуковой эффект через Web Audio API. + * Все звуки генерируются процедурно — никаких mp3-файлов, нагрузка минимальная. + * Поддерживаемые: jump, pickup, win, lose, click, hit, coin. + */ + _playSound(payload) { + if (!payload || typeof payload.name !== 'string') return; + const name = payload.name; + const volume = Number.isFinite(payload.volume) ? Math.max(0, Math.min(2, payload.volume)) : 1; + const pitch = Number.isFinite(payload.pitch) ? Math.max(0.25, Math.min(4, payload.pitch)) : 1; + try { + if (!this._audioCtx) { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return; + this._audioCtx = new Ctx(); + } + const ctx = this._audioCtx; + if (ctx.state === 'suspended') ctx.resume(); + const t = ctx.currentTime; + // Описание звуков: одна или несколько oscillator-волн с envelope + switch (name) { + case 'jump': this._sfxJump(ctx, t, volume, pitch); break; + case 'pickup': this._sfxPickup(ctx, t, volume, pitch); break; + case 'win': this._sfxWin(ctx, t, volume, pitch); break; + case 'lose': this._sfxLose(ctx, t, volume, pitch); break; + case 'click': this._sfxClick(ctx, t, volume, pitch); break; + case 'hit': this._sfxHit(ctx, t, volume, pitch); break; + case 'coin': this._sfxCoin(ctx, t, volume, pitch); break; + default: + this._log('warn', `Неизвестный звук: ${name}`); + } + } catch (e) { + // ignore + } + } + + // === Звуковые пресеты (Web Audio) === + _sfxOsc(ctx, t, type, freq0, freq1, dur, vol) { + const osc = ctx.createOscillator(); + osc.type = type; + osc.frequency.setValueAtTime(freq0, t); + if (freq1 != null) osc.frequency.exponentialRampToValueAtTime(Math.max(1, freq1), t + dur); + const g = ctx.createGain(); + g.gain.setValueAtTime(0, t); + g.gain.linearRampToValueAtTime(vol, t + 0.005); + g.gain.exponentialRampToValueAtTime(0.001, t + dur); + osc.connect(g).connect(ctx.destination); + osc.start(t); + osc.stop(t + dur + 0.02); + } + _sfxJump(ctx, t, vol, pitch) { + // Похож на встроенный звук прыжка PlayerController. + this._sfxOsc(ctx, t, 'sine', 720 * pitch, 440 * pitch, 0.16, 0.22 * vol); + this._sfxOsc(ctx, t, 'sine', 110 * pitch, 60 * pitch, 0.07, 0.35 * vol); + } + _sfxPickup(ctx, t, vol, pitch) { + // Восходящие два тона — «пик-апнул!» + this._sfxOsc(ctx, t, 'square', 880 * pitch, 1320 * pitch, 0.10, 0.20 * vol); + this._sfxOsc(ctx, t + 0.08, 'square', 1320 * pitch, 1760 * pitch, 0.12, 0.16 * vol); + } + _sfxCoin(ctx, t, vol, pitch) { + // Классический «динь-динь» + this._sfxOsc(ctx, t, 'sine', 988 * pitch, 988 * pitch, 0.06, 0.25 * vol); + this._sfxOsc(ctx, t + 0.05, 'sine', 1318 * pitch, 1318 * pitch, 0.18, 0.25 * vol); + } + _sfxWin(ctx, t, vol, pitch) { + // Мажорный аккорд C-E-G по очереди + const notes = [523, 659, 784]; + notes.forEach((f, i) => { + this._sfxOsc(ctx, t + i * 0.08, 'triangle', f * pitch, f * pitch, 0.30, 0.22 * vol); + }); + } + _sfxLose(ctx, t, vol, pitch) { + // Нисходящий «провал» + this._sfxOsc(ctx, t, 'sawtooth', 440 * pitch, 110 * pitch, 0.45, 0.22 * vol); + this._sfxOsc(ctx, t + 0.08, 'sawtooth', 330 * pitch, 80 * pitch, 0.50, 0.18 * vol); + } + _sfxClick(ctx, t, vol, pitch) { + // Короткий «тик» + this._sfxOsc(ctx, t, 'square', 1500 * pitch, 800 * pitch, 0.04, 0.15 * vol); + } + /** + * Создать ParticleSystem в указанной точке. Авто-удаляется через duration сек. + * Делегирует в BabylonScene._spawnParticleEffect, который знает о Babylon. + */ + _spawnParticles(payload) { + if (!payload || !this.scene3d?._spawnParticleEffect) return; + try { + this.scene3d._spawnParticleEffect(payload); + } catch (e) { + this._log('error', 'spawnParticles failed: ' + (e?.message || e)); + } + } + + _sfxHit(ctx, t, vol, pitch) { + // Глухой «тук»: низкий sine + шумовой burst + this._sfxOsc(ctx, t, 'sine', 180 * pitch, 80 * pitch, 0.10, 0.30 * vol); + // Шум через короткий buffer-noise + const bufLen = Math.floor(ctx.sampleRate * 0.06); + const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate); + const data = buf.getChannelData(0); + for (let i = 0; i < bufLen; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / bufLen); + const src = ctx.createBufferSource(); + src.buffer = buf; + const lp = ctx.createBiquadFilter(); + lp.type = 'lowpass'; + lp.frequency.value = 1000 * pitch; + const g = ctx.createGain(); + g.gain.value = 0.18 * vol; + src.connect(lp).connect(g).connect(ctx.destination); + src.start(t); + } + + // === Универсальное хранилище сейвов (game.save.*) === + _saveProjectId() { + return this.scene3d?._currentProjectId || this.scene3d?.projectId || null; + } + _saveBaseUrl(namespace) { + const pid = this._saveProjectId(); + const uid = this.scene3d?._currentUserId; + if (!pid || !uid) return null; + const ns = encodeURIComponent(namespace || 'default'); + return `${STORYS_addres}/kubikon3d/savegame/${pid}/${uid}/${ns}`; + } + _saveReply(scriptId, reqId, result) { + for (const sb of this.sandboxes) { + if (sb.scriptId === scriptId) { + try { sb.worker.postMessage({ cmd: 'saveResponse', payload: { reqId, result } }); } catch (e) {} + return; + } + } + } + _saveGet(scriptId, payload) { + const reqId = payload?.reqId; + const url = this._saveBaseUrl(payload?.namespace); + if (!url) { this._saveReply(scriptId, reqId, null); return; } + // GET savegame теперь тоже требует JWT (бэк ужесточили после + // Этапа 4 — выдаёт 401 без, 403 если чужой). Используем те же + // headers что _saveSet/_saveMerge. + const headers = {}; + try { + const t = localStorage.getItem('Authorization'); + if (t) headers.Authorization = t; + } catch (e) {} + fetch(url, { headers }).then(r => r.json()) + .then(j => this._saveReply(scriptId, reqId, j.data ?? null)) + .catch(() => this._saveReply(scriptId, reqId, null)); + } + _saveGetAll(scriptId, payload) { + const reqId = payload?.reqId; + const pid = this._saveProjectId(); + const uid = this.scene3d?._currentUserId; + if (!pid || !uid) { this._saveReply(scriptId, reqId, {}); return; } + const headers = {}; + try { + const t = localStorage.getItem('Authorization'); + if (t) headers.Authorization = t; + } catch (e) {} + fetch(`${STORYS_addres}/kubikon3d/savegame/${pid}/${uid}`, { headers }) + .then(r => r.json()) + .then(j => this._saveReply(scriptId, reqId, j.namespaces || {})) + .catch(() => this._saveReply(scriptId, reqId, {})); + } + // Превращает относительный путь (/kubikon/gd, /kubikon3d, /app/...) + // во ВНЕШНИЙ URL правильного хоста, потому что в выделенном плеере + // (player.rublox.pro) этих SPA-роутов нет. + // + // Карта роутов (rublox.pro вместо mnk — у юзеров плеера нет сессии + // на mnk, разные домены/localStorage; получался 401): + // - http(s)://... → как есть (уже абсолютный) + // - /kubikon/gd* → rublox.pro/app/gd (порт меню GD) + // - /kubikon/play/N → ticket-flow в плеер уже идёт; + // сюда попасть можно только через + // app.navigate из скрипта уровня + // (Например 'играть ещё раз' → + // /kubikon/play/296?play=1&t=ts). + // Парсим id и шлём прямо в плеер. + // - /kubikon*, /kubikon3d* → rublox.pro/app (лента игр) + // - /app, /app/* → rublox.pro + // - всё остальное → rublox.pro/app (фоллбек) + // + // На localhost — dev-порт rublox-site (3004), на проде — rublox.pro. + _resolveExternalUrl(url) { + try { + // VITE_RUBLOX_HOME = главный сайт-витрина (default: https://rublox.pro/app). + // На dev rublox-site обычно крутится на :3004 — можно переопределить. + const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; + const RUBLOX_HOME = env.VITE_RUBLOX_HOME || 'https://rublox.pro/app'; + const rubloxBase = RUBLOX_HOME.replace(/\/app\/?$/, ''); // .../app → ... + + if (!url) return RUBLOX_HOME; + if (/^https?:\/\//i.test(url)) return url; + + // /kubikon/play/ — рестарт уровня. Перезагружаем плеер сам. + const playMatch = url.match(/^\/kubikon\/play\/(\d+)/); + if (playMatch) { + const playerBase = typeof window !== 'undefined' + ? `${window.location.protocol}//${window.location.host}` + : ''; + return `${playerBase}/${playMatch[1]}`; + } + // Legacy /kubikon/* роуты — редирект на главный сайт. + if (url.startsWith('/kubikon/gd')) return rubloxBase + '/app/gd'; + if (url.startsWith('/kubikon')) return rubloxBase + '/app'; + if (url.startsWith('/app')) return rubloxBase + url; + return rubloxBase + '/app'; + } catch (e) { + const env = (typeof import.meta !== 'undefined' && import.meta.env) || {}; + return env.VITE_RUBLOX_HOME || 'https://rublox.pro/app'; + } + } + // ВАЖНО: POST savegame/merge на бэке требует JWT (no_token → 401). + // Оригинальный код Майнкрафтии fetch БЕЗ Authorization-заголовка + // (этот же баг там тоже есть — сохранения GD-прогресса не работали + // молча, потому что .catch(()=>{}) глушит). В плеере добавляем JWT + // через зеркало localStorage['Authorization'] (см. auth/ticketExchange.js + // saveJWT — он кладёт JWT в оба ключа). + _saveAuthHeaders() { + const h = { 'Content-Type': 'application/json' }; + try { + const t = localStorage.getItem('Authorization'); + if (t) h.Authorization = t; + } catch (e) {} + return h; + } + _saveSet(payload) { + const url = this._saveBaseUrl(payload?.namespace); + if (!url) return; + try { + fetch(url, { + method: 'POST', + headers: this._saveAuthHeaders(), + body: JSON.stringify({ data: payload.data }), + }).catch(() => {}); + } catch (e) {} + } + _saveMerge(payload) { + const url = this._saveBaseUrl(payload?.namespace); + if (!url) return; + try { + fetch(url + '/merge', { + method: 'POST', + headers: this._saveAuthHeaders(), + body: JSON.stringify({ + patch: payload.patch || {}, + increment: payload.increment || {}, + max: payload.max || {}, + }), + }).catch(() => {}); + } catch (e) {} + } + _saveLeaderboard(scriptId, payload) { + const reqId = payload?.reqId; + const pid = this._saveProjectId(); + if (!pid) { this._saveReply(scriptId, reqId, []); return; } + const params = new URLSearchParams({ + namespace: payload?.namespace || '', + key: payload?.key || '', + order: payload?.order || 'desc', + limit: '20', + }); + fetch(`${STORYS_addres}/kubikon3d/savegame/${pid}/leaderboard?${params}`) + .then(r => r.json()) + .then(j => this._saveReply(scriptId, reqId, j.entries || [])) + .catch(() => this._saveReply(scriptId, reqId, [])); + } + + // ============== ECONOMY API (GD-reward через storys) ============== + // Каждый метод асинхронно делает HTTP-запрос с JWT в заголовке Authorization. + // Ответ возвращается в Worker через postMessage cmd='economyResponse'. + + _economyReply(scriptId, reqId, result) { + for (const sb of this.sandboxes) { + if (sb.scriptId === scriptId) { + try { sb.worker.postMessage({ cmd: 'economyResponse', payload: { reqId, result } }); } catch (e) {} + return; + } + } + } + + _economyAuthHeaders() { + const h = { 'Content-Type': 'application/json' }; + try { + const t = localStorage.getItem('Authorization'); + if (t) h.Authorization = t; + } catch (e) {} + return h; + } + + _economyReward(scriptId, payload) { + const reqId = payload?.reqId; + const aid = String(payload?.achievementId || ''); + if (!aid) { this._economyReply(scriptId, reqId, { ok: false, error: 'no_id' }); return; } + fetch(`${STORYS_addres}/kubikon3d/gd/reward`, { + method: 'POST', + headers: this._economyAuthHeaders(), + body: JSON.stringify({ achievement_id: aid }), + }) + .then(r => r.json()) + .then(j => this._economyReply(scriptId, reqId, j || { ok: false })) + .catch(e => this._economyReply(scriptId, reqId, { ok: false, error: String(e) })); + } + + _economyDailyCheck(scriptId, payload) { + const reqId = payload?.reqId; + fetch(`${STORYS_addres}/kubikon3d/gd/daily-check`, { + method: 'POST', + headers: this._economyAuthHeaders(), + body: JSON.stringify({}), + }) + .then(r => r.json()) + .then(j => this._economyReply(scriptId, reqId, j || { awarded: false })) + .catch(e => this._economyReply(scriptId, reqId, { awarded: false, error: String(e) })); + } + + _economyGetBalance(scriptId, payload) { + const reqId = payload?.reqId; + // Алмазы — user/api/v1/users/diamond, рейтинг — user/api/v1/users/rating. + // Делаем оба запроса параллельно. + const USER_BASE = STORYS_addres.replace('/api-storys', '/api-user'); + const headers = this._economyAuthHeaders(); + Promise.all([ + fetch(`${USER_BASE}/api/v1/users/diamond`, { headers }).then(r => r.json()).catch(() => ({ count: 0 })), + fetch(`${USER_BASE}/api/v1/users/rating`, { headers }).then(r => r.json()).catch(() => ({ rating: 0 })), + ]).then(([dm, rt]) => { + this._economyReply(scriptId, reqId, { + diamonds: Number(dm.count || 0), + rating: Number(rt.rating || 0), + }); + }).catch(() => this._economyReply(scriptId, reqId, { diamonds: 0, rating: 0 })); + } + + _economySpend(scriptId, payload) { + const reqId = payload?.reqId; + const amount = Number(payload?.amount || 0); + const reason = String(payload?.reason || 'gd_spend'); + if (amount < 1) { this._economyReply(scriptId, reqId, { ok: false, error: 'invalid_amount' }); return; } + const USER_BASE = STORYS_addres.replace('/api-storys', '/api-user'); + fetch(`${USER_BASE}/api/v1/users/diamond/spend`, { + method: 'POST', + headers: this._economyAuthHeaders(), + body: JSON.stringify({ amount, reason }), + }) + .then(r => r.json()) + .then(j => this._economyReply(scriptId, reqId, j || { ok: false })) + .catch(e => this._economyReply(scriptId, reqId, { ok: false, error: String(e) })); + } +} diff --git a/src/engine/PlacementManager.js b/src/engine/PlacementManager.js new file mode 100644 index 0000000..5a02d8e --- /dev/null +++ b/src/engine/PlacementManager.js @@ -0,0 +1,586 @@ +/** + * PlacementManager — drag-and-drop размещение объектов в 3D-мире (задача 11). + * + * Фундамент жанра tycoon/simulator/farm: «кликнул предмет в инвентаре → + * полупрозрачный preview летает за курсором → ЛКМ ставит, ПКМ/Esc отменяет». + * Аналог placement-системы из Roblox tycoon-игр (Pet Sim, Lumber Tycoon). + * + * Подключается из BabylonScene: `this.placementManager = new PlacementManager(this)`. + * Доступ к движку через scene3d: camera, scene, player, pick, spawn, fx. + * + * Скриптовый API игры (через GameRuntime → game.placement.*): + * start(itemKey, opts) — войти в режим расстановки + * cancel() — выйти (как ПКМ/Esc) + * confirm() — поставить на текущей позиции (как ЛКМ) + * rotate(deg) — повернуть preview (как R / колесо) + * onPlace / onCancel / onMove — колбэки (роутятся в worker как события) + * + * Фича-парность: идентичный модуль есть в rublox-player/src/engine/. + */ +import { MeshBuilder, StandardMaterial, Color3, Vector3 } from '@babylonjs/core'; + +const VALID_TINT = new Color3(0.30, 0.95, 0.40); // зелёный — можно ставить +const INVALID_TINT = new Color3(0.95, 0.25, 0.25); // красный — нельзя + +export class PlacementManager { + constructor(scene3d) { + this.s = scene3d; // BabylonScene + this.scene = scene3d.scene; + this._active = null; // активная сессия placement или null + this._tickObs = null; // observer renderLoop + this._placementSeq = 0; + // Колбэки (вызываются движком, GameRuntime роутит их в worker как события) + this._onPlace = null; + this._onCancel = null; + this._onMove = null; + } + + setCallbacks({ onPlace, onCancel, onMove } = {}) { + if (onPlace !== undefined) this._onPlace = onPlace; + if (onCancel !== undefined) this._onCancel = onCancel; + if (onMove !== undefined) this._onMove = onMove; + } + + isActive() { return !!this._active; } + + /** + * Войти в placement-режим. + * @param {string} itemKey — ключ предмета (передаётся обратно в onPlace) + * @param {object} opts — см. 11_placement_mode.md §2.1 + * @returns {string} placementId + */ + start(itemKey, opts = {}) { + // Уже активна сессия — отменим прежнюю (без onCancel-шума автора). + if (this._active) this._teardown(false); + + const o = { + previewType: opts.previewType || 'primitive:cube', + previewColor: opts.previewColor || '#a0522d', + previewScale: Number(opts.previewScale) || 1, + // modelScale — реальный scale воксельной модели для превью (чтобы + // полупрозрачная копия была того же размера, что и ставимый объект). + modelScale: Number(opts.modelScale) || Number(opts.previewScale) || 1, + ghostOpacity: opts.ghostOpacity != null ? Number(opts.ghostOpacity) : 0.5, + surfaceMode: opts.surfaceMode || 'ground', // 'ground'|'any'|'tag' + allowSurfaces: Array.isArray(opts.allowSurfaces) ? opts.allowSurfaces : null, + forbidOverlap: opts.forbidOverlap !== false, + grid: opts.grid != null ? Number(opts.grid) : 1, + rotationStep: opts.rotationStep != null ? Number(opts.rotationStep) : 90, + targetZone: opts.targetZone || null, // ref-строка примитива-зоны + showZoneOutline: opts.showZoneOutline !== false, + showArrowFrom: opts.showArrowFrom || null, // 'player' | ref + cost: Number(opts.cost) || 0, + currency: opts.currency || 'rubles', + hint: opts.hint || '', + hintError: opts.hintError || 'Разместите в отмеченном месте!', + placedType: opts.placedType || null, + chainPlace: !!opts.chainPlace, + maxDistance: opts.maxDistance != null ? Number(opts.maxDistance) : 0, + maxItems: opts.maxItems != null ? Number(opts.maxItems) : 0, + forceCameraMode: opts.forceCameraMode !== false, + freezePlayer: !!opts.freezePlayer, + previewPulse: opts.previewPulse !== false, + }; + + const id = 'placement_' + (++this._placementSeq); + const preview = this._createPreview(o); + + this._active = { + id, itemKey, opts: o, preview, + rotationY: 0, + valid: false, + pos: new Vector3(0, 0, 0), + zoneOutline: null, + arrowFxRef: null, + placedCount: 0, + pulseT: 0, + prevCameraMode: null, + prevFrozen: null, + }; + + // Зона размещения — красный контур по AABB. + if (o.targetZone && o.showZoneOutline) this._createZoneOutline(); + // Стрелка от игрока/объекта к зоне (переиспользует fx.pointer задачи 08). + if (o.showArrowFrom && o.targetZone) this._createArrow(); + // Камера: placement требует видимый курсор — в first переводим в third. + if (o.forceCameraMode) this._forceThirdCamera(); + // Заморозка игрока (опция). + if (o.freezePlayer) this._setPlayerFrozen(true); + + // HUD: подсказки снизу-справа + верхний hint. Сообщаем движку. + this._emitHud(true); + + this._startTick(); + return id; + } + + cancel() { + if (!this._active) return; + const cb = this._onCancel; + this._teardown(true); + if (typeof cb === 'function') cb(); + } + + /** Поставить на текущей позиции (как ЛКМ). */ + confirm() { + const a = this._active; + if (!a) return false; + if (!a.valid) { + // Невалидно — звук «не получилось» + мигание preview в красный. + this._playFail(); + this._flashInvalid(); + return false; + } + // Позиция для spawn = точка курсора МИНУС offset центра модели (с учётом + // поворота), чтобы реальный объект встал ОСНОВАНИЕМ под курсором — + // ровно туда, где показывалось превью. Для куба-превью offset = 0. + let ox = a._modelOffsetX || 0, oz = a._modelOffsetZ || 0; + if (ox || oz) { + const c = Math.cos(a.rotationY), s = Math.sin(a.rotationY); + const rx = ox * c - oz * s; + const rz = ox * s + oz * c; + ox = rx; oz = rz; + } + const result = { + itemKey: a.itemKey, + position: { x: a.pos.x - ox, y: a.pos.y, z: a.pos.z - oz }, + rotationY: a.rotationY, + }; + // Списание стоимости (если задана и есть валюта-хелпер в движке). + if (a.opts.cost > 0) this._spendCurrency(a.opts.currency, a.opts.cost); + a.placedCount++; + this._playPlace(); + + if (typeof this._onPlace === 'function') this._onPlace(result); + + if (a.opts.chainPlace) { + // Остаёмся в режиме для следующего — preview не удаляем, сбрасываем поворот? нет, сохраняем. + // Просто продолжаем тик; valid пересчитается в следующем кадре. + return true; + } + this._teardown(false); + return true; + } + + /** Повернуть preview на N градусов вокруг Y. */ + rotate(deg) { + const a = this._active; + if (!a) return; + const step = (deg != null ? Number(deg) : a.opts.rotationStep) || 90; + a.rotationY = (a.rotationY + step * Math.PI / 180) % (Math.PI * 2); + if (a.preview) a.preview.rotation.y = a.rotationY; + } + + // ── Внутреннее ────────────────────────────────────────────────────── + + _createPreview(o) { + const base = Color3.FromHexString(o.previewColor || '#a0522d'); + + // Для воксельной модели (user:) строим ПРЕВЬЮ ИЗ РЕАЛЬНОЙ ГЕОМЕТРИИ + // модели — полупрозрачную копию. Так тень точно повторяет форму предмета + // И совпадает по позиционированию с реальным spawn (модель растёт от угла + // root, а не центрируется — куб-превью раньше центрировался → предмет + // вставал в угол превью). Здесь превью = тот же addInstance, поэтому + // угол-в-угол. Делается асинхронно (см. _buildUserModelPreview). + const pt = o.previewType || ''; + if (pt.indexOf('user:') === 0 && this.s.userModelManager) { + // Временный куб-заглушка пока модель грузится (1-2 кадра), заменим. + const stub = MeshBuilder.CreateBox('placementGhostStub', { size: 0.01 }, this.scene); + stub.isPickable = false; + stub._baseColor = base; + this._buildUserModelPreview(pt, o, base); + return stub; + } + + // Примитивы / прочее — полупрозрачный куб размером previewScale (юниты). + const edge = Number(o.previewScale) || 1; + const ghost = MeshBuilder.CreateBox('placementGhost', { size: edge }, this.scene); + const mat = new StandardMaterial('placementGhostMat', this.scene); + mat.diffuseColor = base; + mat.emissiveColor = base.scale(0.25); + mat.specularColor = new Color3(0, 0, 0); + mat.alpha = o.ghostOpacity; + mat.disableLighting = true; + ghost.material = mat; + ghost.isPickable = false; + ghost._baseColor = base; + return ghost; + } + + /** Построить полупрозрачное превью из реальной воксельной модели (async). */ + async _buildUserModelPreview(previewType, o, base) { + try { + const um = this.s.userModelManager; + // Спавним реальный инстанс модели в (0,0,0) — будем двигать как превью. + const instId = await um.addInstance(previewType, 0, 0, 0, 0, { + scale: o.modelScale || o.previewScale || 1, + canCollide: false, visible: true, anchored: true, + currentUserId: this.s._currentUserId || null, + }); + if (instId == null) return; + // Сессия уже могла завершиться/смениться, пока грузилось. + const a = this._active; + if (!a) { try { um.removeInstance(instId); } catch (e) {} return; } + const inst = um.instances.get(instId); + if (!inst || !inst.rootNode) return; + // Применяем ghost-вид ко всем мешам модели: полупрозрачно, не pickable. + const ghostMat = new StandardMaterial('placementGhostMatUM', this.scene); + ghostMat.diffuseColor = base; + ghostMat.emissiveColor = base.scale(0.25); + ghostMat.specularColor = new Color3(0, 0, 0); + ghostMat.alpha = o.ghostOpacity; + ghostMat.disableLighting = true; + ghostMat.backFaceCulling = false; + for (const m of (inst.meshes || [])) { + m.isPickable = false; + m.material = ghostMat; + } + // Центр модели по X/Z (воксели растут углом от root → центр смещён). + // Вычисляем из bbox мешей в локальных координатах root (root в 0,0,0). + // Этот offset вычитаем из позиции, чтобы ОСНОВАНИЕ модели (её центр + // по X/Z) было ровно под курсором, а не угол. Применяется и к превью, + // и к реальному spawn (onPlace) — иначе предмет «уезжает» по диагонали. + let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity; + for (const m of (inst.meshes || [])) { + m.computeWorldMatrix(true); + const bb = m.getBoundingInfo().boundingBox; + minX = Math.min(minX, bb.minimumWorld.x); maxX = Math.max(maxX, bb.maximumWorld.x); + minZ = Math.min(minZ, bb.minimumWorld.z); maxZ = Math.max(maxZ, bb.maximumWorld.z); + } + const offX = Number.isFinite(minX) ? (minX + maxX) / 2 : 0; + const offZ = Number.isFinite(minZ) ? (minZ + maxZ) / 2 : 0; + a._modelOffsetX = offX; + a._modelOffsetZ = offZ; + + // Удаляем временный stub, новый root становится превью. + const old = a.preview; + a.preview = inst.rootNode; + a.preview._baseColor = base; + a.preview._userModelInstId = instId; // для teardown + a.preview._ghostMat = ghostMat; + if (old) { try { old.dispose(); } catch (e) {} } + } catch (e) { + // тихо — превью некритично, останется stub + } + } + + _startTick() { + this._tickObs = this.scene.onBeforeRenderObservable.add(() => this._tick()); + } + + _tick() { + const a = this._active; + if (!a) return; + const scn = this.scene; + + // Raycast от камеры через текущую позицию курсора. + const pick = scn.pick(scn.pointerX, scn.pointerY, (m) => + m && m.isPickable && m !== a.preview && this._isSurface(m, a.opts)); + if (pick && pick.hit && pick.pickedPoint) { + let p = pick.pickedPoint.clone(); + // surfaceMode 'ground' — нормаль должна смотреть вверх. + // Поверхность валидна, если смотрит вверх (горизонтальная грань). + // Это и пол, и ВЕРХ другого объекта → можно строить стопкой. + let surfOk = true; + if (a.opts.surfaceMode === 'ground') { + const n = pick.getNormal(true); + surfOk = n && n.y > 0.6; // только грань, обращённая вверх + } + // Снэппинг к сетке (X/Z). Y берём с поверхности — чтобы объект + // лёг ровно сверху на пол ИЛИ на другой объект (стопка). + if (a.opts.grid > 0) { + p.x = Math.round(p.x / a.opts.grid) * a.opts.grid; + p.z = Math.round(p.z / a.opts.grid) * a.opts.grid; + } + a.pos.copyFrom(p); + if (a.preview) { + if (a.preview._userModelInstId != null) { + // userModel-превью: root = угол модели. Вычитаем offset центра + // по X/Z → ОСНОВАНИЕ модели (ствол/центр) точно под курсором. + // Высота p.y без сдвига (низ модели на поверхность). + a.preview.position.set( + p.x - (a._modelOffsetX || 0), + p.y, + p.z - (a._modelOffsetZ || 0), + ); + } else { + // Куб-превью центрирован → поднимаем на полвысоты. + a.preview.position.set(p.x, p.y + 0.5 * a.opts.previewScale, p.z); + } + } + // Валидность. forbidOverlap теперь означает «не врезаться вбок в + // объект на ТОЙ ЖЕ высоте» — вертикальная стопка разрешена. + a.valid = surfOk + && this._inZone(p, a.opts) + && this._distanceOk(p, a.opts) + && this._limitOk(a.opts) + && this._affordable(a) + && (!a.opts.forbidOverlap || !this._overlapsSide(p, a)); + } else { + a.valid = false; + } + + // Цвет preview: зелёный/красный. + this._applyTint(a, a.valid); + + // Пульсация прозрачности (привлекает внимание). Материал — у куба-превью + // напрямую, у userModel-превью в _ghostMat (root — TransformNode без mat). + const pmat = a.preview && (a.preview.material || a.preview._ghostMat); + if (a.opts.previewPulse && pmat) { + a.pulseT += this.scene.getEngine().getDeltaTime() / 1000; + const k = 0.5 + 0.5 * Math.sin(a.pulseT * Math.PI); // 0..1 + pmat.alpha = a.opts.ghostOpacity * (0.7 + 0.3 * k); + } + + // HUD-индикатор ошибки (красный текст когда невалидно). + this._emitHudError(!a.valid); + + // Стрелка к зоне — обновим конечную точку (если игрок движется). + if (a.arrowFxRef) this._updateArrow(); + + // onMove колбэк автору (каждый кадр). + if (typeof this._onMove === 'function') { + this._onMove({ position: { x: a.pos.x, y: a.pos.y, z: a.pos.z }, valid: a.valid }); + } + } + + _applyTint(a, valid) { + // Материал куба-превью напрямую, userModel-превью — в _ghostMat. + const pmat = a.preview && (a.preview.material || a.preview._ghostMat); + if (!pmat) return; + if (a._flashUntil && performance && performance.now && performance.now() < a._flashUntil) { + return; // во время flash держим красный + } + const tint = valid ? VALID_TINT : INVALID_TINT; + // Смешиваем базовый цвет с tint-ом (multiply-эффект). + const b = a.preview._baseColor || new Color3(0.6, 0.4, 0.25); + pmat.diffuseColor = new Color3( + b.r * tint.r + tint.r * 0.4, + b.g * tint.g + tint.g * 0.4, + b.b * tint.b + tint.b * 0.4, + ); + pmat.emissiveColor = tint.scale(0.35); + } + + _flashInvalid() { + const a = this._active; + if (!a || !a.preview || !a.preview.material) return; + try { a._flashUntil = (performance.now ? performance.now() : Date.now()) + 300; } catch { a._flashUntil = Date.now() + 300; } + a.preview.material.diffuseColor = INVALID_TINT; + a.preview.material.emissiveColor = INVALID_TINT.scale(0.6); + } + + _isSurface(mesh, o) { + if (!o.allowSurfaces) return true; // любая поверхность + // Совпадение по имени или тегу. + const name = mesh.name || ''; + if (o.allowSurfaces.some(s => name.includes(s))) return true; + const tags = mesh.metadata && mesh.metadata.tags; + if (Array.isArray(tags) && tags.some(t => o.allowSurfaces.includes(t))) return true; + return false; + } + + _inZone(p, o) { + if (!o.targetZone) return true; + const z = this._resolveZoneMesh(o.targetZone); + if (!z) return true; + const bb = z.getBoundingInfo().boundingBox; + const min = bb.minimumWorld, max = bb.maximumWorld; + return p.x >= min.x && p.x <= max.x && p.z >= min.z && p.z <= max.z; + } + + _distanceOk(p, o) { + if (!o.maxDistance || o.maxDistance <= 0) return true; + const pl = this.s.player && this.s.player._pos; + if (!pl) return true; + const dx = p.x - pl.x, dz = p.z - pl.z; + return (dx * dx + dz * dz) <= o.maxDistance * o.maxDistance; + } + + _limitOk(o) { + if (!o.maxItems || o.maxItems <= 0) return true; + return (this._active.placedCount || 0) < o.maxItems; + } + + _overlapsSide(p, a) { + // Боковое пересечение: объект мешает ТОЛЬКО если он на той же высоте + // (его тело пересекает уровень, куда ляжет новый объект). Объект строго + // НИЖЕ (фундамент под стопку) или строго ВЫШЕ — не мешает. Это позволяет + // строить башню из кубов, но не даёт двум кубам слипнуться вбок. + const r = Math.max(0.45, (a.opts.grid || 1) * 0.5); + const newY = p.y; // высота поверхности (низ нового объекта) + const newTop = newY + (a.opts.previewScale || 1); + for (const m of this.scene.meshes) { + if (!m.isPickable || m === a.preview) continue; + if (!m.getBoundingInfo) continue; + const bb = m.getBoundingInfo().boundingBox; + const sizeX = bb.maximumWorld.x - bb.minimumWorld.x; + if (sizeX > 8) continue; // пол/большая поверхность — не препятствие + const c = bb.centerWorld; + const dx = c.x - p.x, dz = c.z - p.z; + if (Math.abs(dx) >= r || Math.abs(dz) >= r) continue; // не под курсором + const mTop = bb.maximumWorld.y, mBot = bb.minimumWorld.y; + // Пересечение по вертикали: тела перекрываются по Y → бок в бок. + const overlapY = newY < (mTop - 0.05) && newTop > (mBot + 0.05); + if (overlapY) return true; + } + return false; + } + + /** Хватает ли валюты на текущий предмет (если задан баланс). */ + _affordable(a) { + const cur = a.opts.currency; + const cost = a.opts.cost || 0; + if (!cost) return true; + const bal = (this._balances && this._balances[cur] != null) ? this._balances[cur] : Infinity; + return cost <= bal; + } + + /** Установить баланс валюты (для проверки «нельзя уйти в минус»). */ + setBalance(currency, amount) { + if (!this._balances) this._balances = {}; + if (currency) this._balances[currency] = Number(amount) || 0; + } + + _resolveZoneMesh(ref) { + // ref может быть строкой ('primitive:N' / имя) или уже мешем. + if (ref && ref.getBoundingInfo) return ref; + if (typeof ref === 'string') { + // через scene3d — найти примитив/модель по ref + try { + const mesh = this.s._refStrToMesh ? this.s._refStrToMesh(ref) : null; + if (mesh) return mesh; + } catch { /* ignore */ } + // fallback — по имени + return this.scene.getMeshByName(ref) || null; + } + return null; + } + + _createZoneOutline() { + const a = this._active; + const z = this._resolveZoneMesh(a.opts.targetZone); + if (!z) return; + const bb = z.getBoundingInfo().boundingBox; + const min = bb.minimumWorld, max = bb.maximumWorld; + const y = min.y + 0.06; + const pts = [ + new Vector3(min.x, y, min.z), new Vector3(max.x, y, min.z), + new Vector3(max.x, y, max.z), new Vector3(min.x, y, max.z), + new Vector3(min.x, y, min.z), + ]; + const line = MeshBuilder.CreateLines('placementZoneOutline', { points: pts }, this.scene); + line.color = new Color3(1, 0.19, 0.19); + line.isPickable = false; + // glow-имитация: чуть приподнятая полупрозрачная плоскость + a.zoneOutline = line; + } + + _createArrow() { + const a = this._active; + // Стрелка-указатель через BeamManager (задача 08: addPointer/setPointerTarget). + try { + const bm = this.s.beamManager; + if (!bm || !bm.addPointer) return; + const z = this._resolveZoneMesh(a.opts.targetZone); + if (!z) return; + const c = z.getBoundingInfo().boundingBox.centerWorld; + const from = (a.opts.showArrowFrom === 'player' && this.s.player && this.s.player._pos) + ? this.s.player._pos + : this._resolveZoneMesh(a.opts.showArrowFrom); + const fromV = from && from.x != null ? new Vector3(from.x, (from.y || 0) + 1, from.z) : null; + if (!fromV) return; + a.arrowFxRef = bm.addPointer({ + from: { x: fromV.x, y: fromV.y, z: fromV.z }, + to: { x: c.x, y: c.y + 0.6, z: c.z }, + preset: 'guide', + }); + } catch { /* стрелка не критична */ } + } + + _updateArrow() { + // Стрелка статична от точки старта к зоне (как в Roblox tycoon — + // указатель «куда ставить»). BeamManager не имеет setPointerOrigin, + // а пересоздавать каждый кадр дорого. Конец уже привязан к зоне. + } + + _forceThirdCamera() { + const a = this._active; + try { + if (this.s.player && this.s.player.getCameraMode && this.s.player.setCameraMode) { + a.prevCameraMode = this.s.player.getCameraMode(); + if (a.prevCameraMode === 'first') this.s.player.setCameraMode('third'); + } + } catch { /* ignore */ } + } + + _setPlayerFrozen(frozen) { + try { + if (this.s.player && this.s.player.setFrozen) { + if (this._active) this._active.prevFrozen = true; + this.s.player.setFrozen(frozen); + } + } catch { /* ignore */ } + } + + _spendCurrency(currency, amount) { + // Движок не держит «кошелёк» — это делает игра через onPlace + save. + // Но если есть хелпер валюты, вызовем. Иначе — no-op (автор сам спишет). + try { + if (this.s.spendCurrency) this.s.spendCurrency(currency, amount); + } catch { /* ignore */ } + } + + _playPlace() { try { this.s.gameAudioManager && this.s.gameAudioManager.playUi && this.s.gameAudioManager.playUi('place'); } catch { /* ignore */ } } + _playFail() { try { this.s.gameAudioManager && this.s.gameAudioManager.playUi && this.s.gameAudioManager.playUi('lose'); } catch { /* ignore */ } } + + _emitHud(show) { + // Сообщаем движку показать/скрыть placement-HUD (подсказки). + try { + if (this.s.onPlacementHud) this.s.onPlacementHud({ show, hint: this._active ? this._active.opts.hint : '' }); + } catch { /* ignore */ } + } + + _emitHudError(isError) { + try { + if (this.s.onPlacementHudError) this.s.onPlacementHudError(isError); + } catch { /* ignore */ } + } + + _teardown(emitHudOff) { + const a = this._active; + if (!a) return; + if (this._tickObs) { this.scene.onBeforeRenderObservable.remove(this._tickObs); this._tickObs = null; } + if (a.preview) { + try { + if (a.preview._userModelInstId != null && this.s.userModelManager) { + // userModel-превью — это реальный инстанс; удаляем через менеджер + // (снимет из Map + dispose мешей). + чистим ghost-материал. + try { a.preview._ghostMat && a.preview._ghostMat.dispose(); } catch (e) {} + this.s.userModelManager.removeInstance(a.preview._userModelInstId); + } else { + a.preview.material && a.preview.material.dispose(); + a.preview.dispose(); + } + } catch { /* ignore */ } + } + if (a.zoneOutline) { try { a.zoneOutline.dispose(); } catch { /* ignore */ } } + if (a.arrowFxRef != null && this.s.beamManager && this.s.beamManager.remove) { + try { this.s.beamManager.remove(a.arrowFxRef); } catch { /* ignore */ } + } + if (a.prevCameraMode === 'first' && this.s.player && this.s.player.setCameraMode) { + try { this.s.player.setCameraMode('first'); } catch { /* ignore */ } + } + if (a.prevFrozen && this.s.player && this.s.player.setFrozen) { + try { this.s.player.setFrozen(false); } catch { /* ignore */ } + } + this._active = null; + if (emitHudOff !== false) this._emitHud(false); + } + + /** Полный сброс при Stop игры. */ + dispose() { + this._teardown(true); + this._onPlace = this._onCancel = this._onMove = null; + } +} diff --git a/src/engine/ScriptSandboxWorker.js b/src/engine/ScriptSandboxWorker.js index 286be21..529161f 100644 --- a/src/engine/ScriptSandboxWorker.js +++ b/src/engine/ScriptSandboxWorker.js @@ -1,3726 +1,3888 @@ -/** - * ScriptSandboxWorker.js — код, исполняющийся внутри Web Worker. - * - * НЕ импортируется напрямую через ES-import. Загружается через blob-URL, - * созданный в ScriptSandbox.js. - * - * Архитектура: пользовательский скрипт получает ТОЛЬКО объект `game`. - * Любая операция (двигать игрока, лог) превращается в команду - * postMessage в main thread. Main thread исполняет на Babylon-сцене и - * присылает обратно state-update'ы. - * - * API (этап 2.3.1): - * game.player.position — {x, y, z}, обновляется main thread'ом - * game.player.teleport(x, y, z) - * game.onTick(fn) — fn(dt) каждый кадр - * game.log(...args) — лог в Console - * - * game.self — {kind, ref, position} — объект-носитель - * (только для скриптов с target) - * game.self.onClick(fn) — fn(event) при клике по объекту в Play - * game.self.onTouch(fn) — fn(event) когда игрок касается объекта - * game.self.move(x, y, z) — переместить объект (для моделей/примитивов) - * game.self.delete() — удалить объект-носитель - */ - -const SOURCE = ` -"use strict"; - -// === Внутреннее состояние Worker'а === -let _tickHandlers = []; -let _playerState = { - position: { x: 0, y: 0, z: 0 }, - yaw: 0, - pitch: 0, - forward: { x: 0, y: 0, z: 1 }, // нормализованный вектор взгляда - crosshair: 'none', // 'none' | 'dot' | 'cross' | 'circle' - hp: 100, - maxHp: 100, - state: 'ground', // 'ground' | 'air' | 'water' - keys: {}, // { 'w': true, 'space': true } — зажатые сейчас клавиши -}; -// target скрипта (если есть) — пришёл при init -let _target = null; -// Зеркало position объекта-носителя (если target.kind != null) -let _selfPosition = { x: 0, y: 0, z: 0 }; -// Снимок живых мобов — обновляется каждый tick из main thread -let _mobs = []; -// Снимок NPC (Фаза 4.1) — обновляется каждый tick из main thread. -// Каждый: { id, name, x, y, z, hp, maxHp, mode }. -let _npcs = []; -// Счётчик локальных ref'ов для NPC, заспавненных скриптом. -let _npcRefSeq = 0; -// Маппинг локальный ref ('npc:_local_N') → реальный числовой npcId. -// Заполняется когда main thread присылает 'npcSpawned' после async-спавна. -let _npcLocalToReal = {}; -// Маппинг локальный ref scene.spawn ('primitive:_local_N') → реальный -// ('primitive:N'). main thread шлёт 'spawnResolved' после создания. -// Нужно чтобы getPosition и др. находили заспавненный объект в _sceneIndex. -let _spawnLocalToReal = {}; -// Подписки npc.onDeath: ключ = локальный ref ИЛИ строка-id → [fn]. -let _npcDeathHandlers = {}; -// Глобальные подписки game.onNpcDeath(fn). -let _globalNpcDeathHandlers = []; -// Снимок инвентаря (Фаза 4.2): { slots: [...], activeIndex }. -let _inventory = { slots: [], activeIndex: 0 }; -// Подписки game.player.onToolUse(fn). -let _toolUseHandlers = []; -// Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }. -let _players = { me: null, list: [] }; -// Общее состояние комнаты game.room.get/set — зеркало из main thread. -let _roomState = {}; -// Подписки game.onPlayerJoin / onPlayerLeave / game.onMessage(name). -let _playerJoinHandlers = []; -let _playerLeaveHandlers = []; -// Подписки game.onCutsceneDone(fn) — катсцена камеры доиграла (Фаза 5.7). -let _cutsceneDoneHandlers = []; -let _mpMessageHandlers = {}; // name → [fn] -// Подписки game.room.onChange(key, fn): key → [fn]. -let _roomChangeHandlers = {}; -// Команды (Фаза 4.4): массив { name, color } — зеркало из main thread. -let _teams = []; -// Счётчик локальных ref'ов для связей-constraints (Фаза 5). -let _constraintRefSeq = 0; -// Счётчик локальных ref'ов для лучей/следов (Фаза 5.2). -let _fxRefSeq = 0; -// Счётчик локальных ref'ов для звуков (Фаза 5.5). -let _soundRefSeq = 0; -// Подписки на события объекта (self.*) -let _selfClickHandlers = []; -let _selfTouchHandlers = []; -let _selfUntouchHandlers = []; -// Подписки self.onInteract — взаимодействие по клавише E (ProximityPrompt) -let _selfInteractHandlers = []; -// Подписки на касание/клик ПРОИЗВОЛЬНОГО объекта (findOne(x).onTouch). -// ref → { touch:[fn], untouch:[fn], click:[fn] }. Движок следит за AABB этих -// объектов (cmd 'inst.watchTouch') и шлёт обратно instTouch/instUntouch/instClick. -const _instTouchHandlers = new Map(); -function _instHandlerBucket(ref) { - let b = _instTouchHandlers.get(ref); - if (!b) { b = { touch: [], untouch: [], click: [] }; _instTouchHandlers.set(ref, b); } - return b; -} -// Зеркало всех GUI-элементов (синхронизируется main через cmd='guiSnapshot') -let _guiIndex = []; -// Задача 07: зеркало скинов (синхронизируется через 'skinsSnapshot'). -// _skinsIndex — все встроенные скины [{slug,name,kind,category,price}]. -// _unlockedSkins — slug'и доступные игроку. _currentSkin — активный. -let _skinsIndex = []; -let _unlockedSkins = []; -let _currentSkin = null; -let _skinChangeHandlers = []; -let _skinCoins = 0; // локальная валюта магазина скинов (рублики проекта) -// Подписки game.gui.onClick(id, fn) -let _guiClickHandlers = {}; -// Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter) -let _guiSubmitHandlers = {}; -// Подписки game.billboard.onClick(ref, buttonId, fn) — клик по кнопке -// 3D-таблички. Ключ имеет вид ref + ":" + buttonId где ref — строка -// из game.scene.spawn() или game.scene.findOne() в формате -// 'primitive:NN' либо локальный '_local_X'. Резолв в main (GameRuntime), -// сюда приходит событие 'billboardClick' с готовым ref='primitive:NN'. -let _billboardClickHandlers = {}; -// Для GUI-события с реальным id вернуть набор ключей, под которыми -// Нормализовать точку для fx.beam/fx.pointer перед postMessage. -// game.scene.findOne() возвращает Instance-PROXY — его НЕЛЬЗЯ передать через -// postMessage (structured clone бросает DataCloneError → весь скрипт молча -// падает в воркере, стрелка/луч не создаётся). Конвертируем proxy/объект-с-ref -// в ref-строку ('primitive:NN'); 'player' и {x,y,z} пропускаем как есть. -function _normFxPoint(p) { - if (p == null) return p; - if (typeof p === 'string') return p; // 'player' | 'primitive:NN' - if (typeof p === 'object') { - if (typeof p.ref === 'string') return p.ref; // Instance-proxy - if (Number.isFinite(p.x) && Number.isFinite(p.y) && Number.isFinite(p.z)) { - return { x: p.x, y: p.y, z: p.z }; // чистая точка - } - try { const s = String(p); if (s && s !== '[object Object]') return s; } catch (e) {} - } - return p; -} - -// могли быть зарегистрированы handlers: сам id + имя элемента (скрипт -// часто подписывается через game.gui.onClick('ИмяКнопки', fn)). -function _guiHandlerKeys(id, localId) { - const keys = [id]; - // localId — ref который вернул gui.create() ('_gui_local_N'); скрипт мог - // подписаться по нему, если не задавал явный id. - if (localId != null && localId !== id) keys.push(localId); - // name элемента — скрипт часто подписывается game.gui.onClick('ИмяКнопки', fn). - const el = _guiIndex.find(g => g.id === id); - if (el && el.name && el.name !== id && !keys.includes(el.name)) keys.push(el.name); - return keys; -} - -// Найти запись NPC в снапшоте по локальному ref. Снапшот приходит с -// числовыми id, локальный ref → id через _npcLocalToReal. -function _findNpcState(localRef) { - const id = _npcLocalToReal[localRef]; - if (id == null) return null; - return _npcs.find(n => n.id === id) || null; -} - -// Фабрика прокси-объекта NPC. Методы шлют команды с локальным ref — -// main thread резолвит его в реальный npcId. -function _makeNpcProxy(ref) { - return { - get ref() { return ref; }, - /** Актуальная позиция NPC {x,y,z} или null (пока не заспавнен). */ - get position() { - const st = _findNpcState(ref); - return st ? { x: st.x, y: st.y, z: st.z } : null; - }, - /** Текущее HP или null. */ - get hp() { - const st = _findNpcState(ref); - return st ? st.hp : null; - }, - /** Имя NPC или null. */ - get name() { - const st = _findNpcState(ref); - return st ? st.name : null; - }, - /** Идти в точку (XZ). */ - moveTo(x, z) { - _send('npc.moveTo', { ref, x: Number(x) || 0, z: Number(z) || 0 }); - }, - /** Задать скорость NPC (м/с) — на лету. Напр. медленный подход - * в кат-сцене → быстрая погоня в игре. */ - setSpeed(speed) { - const s = Number(speed); - if (Number.isFinite(s) && s > 0) { - _send('npc.setSpeed', { ref, speed: s }); - } - }, - /** Следовать за объектом: 'player' или ref объекта сцены. */ - follow(target) { - _send('npc.follow', { ref, target }); - }, - /** Остановиться. */ - stop() { - _send('npc.stop', { ref }); - }, - /** Реплика над головой на duration секунд (по умолчанию 3). */ - say(text, duration) { - _send('npc.say', { - ref, - text: String(text == null ? '' : text), - duration: Number.isFinite(Number(duration)) ? Number(duration) : 3, - }); - }, - /** Нанести урон NPC. */ - damage(amount) { - _send('npc.damage', { ref, amount: Number(amount) || 0 }); - }, - /** Убрать NPC со сцены. */ - remove() { - _send('npc.remove', { ref }); - }, - /** Колбэк при гибели этого NPC. fn получает {id, position}. */ - onDeath(fn) { - if (typeof fn === 'function') { - (_npcDeathHandlers[ref] = _npcDeathHandlers[ref] || []).push(fn); - } - }, - }; -} -// Глобальные подписки -let _globalKeyDownHandlers = {}; // { 'w': [fn, fn], ... } — ключи нормализованы в lower-case -let _globalKeyUpHandlers = {}; -let _globalClickHandlers = []; -let _globalTouchHandlers = []; -// Колбэки на движение мыши в UI-режиме (game.input.onMouseMove) -let _mouseMoveHandlers = []; -let _mouseDownHandlers = []; -let _mouseUpHandlers = []; -// Колбэк на убийство моба (зомби и т.п.) — fn({mobType, position}) -let _mobKilledHandlers = []; - -// SaveGame API: счётчик request-id и map колбэков по reqId -let _saveReqSeq = 0; -const _saveCallbacks = {}; -// Economy API (GD-награды): request-id → callback -let _economyReqSeq = 0; -const _economyCallbacks = {}; -// Колбэк на изменение HP игрока (для логирования урона/смерти из скриптов) -let _hpChangeHandlers = []; -// Подписки на события игрока: смерть / прыжок / приземление -let _playerDiedHandlers = []; -let _playerJumpHandlers = []; -let _playerLandHandlers = []; -// Broadcast между sandbox'ами: имя сообщения → массив обработчиков -let _messageHandlers = {}; -// Счётчик для локальных ref'ов спавненных через game.scene.spawn -let _localRefSeq = 0; - -// Твины (game.tween): id → callback onDone. Сами твины крутит main-thread -// (GameRuntime), сюда возвращается только событие завершения по reqId. -let _tweenSeq = 0; -const _tweenCallbacks = {}; - -// Таймеры (game.after / game.every). Каждый: { id, fn, delay, elapsed, repeat } -// repeat=false → after (один раз), repeat=true → every (циклично). -// Тикаются в обработчике cmd='tick' по накоплению dt. -let _timers = []; -let _timerSeq = 0; -// Зеркало всех объектов сцены (заполняется main через cmd='sceneSnapshot' каждый раз -// когда что-то меняется). Это позволяет game.scene.find/all/get работать синхронно. -// { blocks: [{ref, type, x, y, z, name?}], models: [...], primitives: [...] } -let _sceneIndex = { blocks: [], models: [], primitives: [] }; - -// Карта высот гладкого ландшафта — для game.scene.surfaceY(x,z). -// Приходит один раз через cmd='terrainHeightmap'. Формат: -// { origin:{x,z}, step, cols, rows, heights:number[] } -let _terrainHM = null; - -// Атрибуты объектов (game.scene.setData/getData). Зеркало из main thread. -// Формат: { ref: { key: value, ... } }. Синхронизируется через cmd='dataSnapshot'. -// getData читает отсюда синхронно, setData шлёт команду в main. -let _dataIndex = {}; - -// ═══════════════════════════════════════════════════════════════════════ -// Instance-proxy (паритет со студией): game.scene.find/findOne/all возвращают -// Proxy с методами (onTouch/onUntouch/onClick, tween, move, changed.connect, -// position/name/parent/children и т.д.). Coerces в строку-ref через -// Symbol.toPrimitive/valueOf/toString, поэтому старый код (scene.setColor и -// т.п., принимавший строковый ref) продолжает работать без изменений. -const _instCache = new Map(); // ref → Instance proxy -const _instEvents = new Map(); // ref → { propChanged: [{prop,fn}], destroying: [fn] } -const _instLastValues = new Map(); // ref → { x, y, z, name } предыдущий snapshot - -function _safeCall2(fn, args, where) { - try { fn.apply(null, args); } - catch (err) { - _send('log', { - level: 'error', - text: 'Ошибка в ' + where + ': ' + (err && err.message ? err.message : err), - }); - } -} - -function _getOrCreateInstance(ref, kindHint) { - if (!ref || typeof ref !== 'string') return null; - if (_instCache.has(ref)) return _instCache.get(ref); - - const target = { - get ref() { return ref; }, - get kind() { - if (kindHint) return kindHint; - for (const b of _sceneIndex.blocks) if (b.ref === ref) return 'block'; - for (const m of _sceneIndex.models) if (m.ref === ref) return 'model'; - for (const p of _sceneIndex.primitives) if (p.ref === ref) return 'primitive'; - return 'unknown'; - }, - toString() { return ref; }, - }; - - const proxy = new Proxy(target, { - get(t, prop) { - if (prop === 'ref' || prop === 'kind') return t[prop]; - if (prop === 'toString') return t.toString; - if (prop === Symbol.toPrimitive) return () => ref; - if (prop === 'valueOf') return () => ref; - - // === Геттеры из snapshot === - if (prop === 'position') { - for (const arr of [_sceneIndex.blocks, _sceneIndex.models, _sceneIndex.primitives]) { - for (const o of arr) if (o.ref === ref) return { x: o.x, y: o.y, z: o.z }; - } - return null; - } - if (prop === 'name') { - for (const arr of [_sceneIndex.models, _sceneIndex.primitives]) { - for (const o of arr) if (o.ref === ref) return o.name || null; - } - return null; - } - if (prop === 'parent') { - const data = _dataIndex[ref]; - const parentRef = data && data.__parent; - return parentRef ? _getOrCreateInstance(parentRef) : null; - } - if (prop === 'children') { - const data = _dataIndex[ref]; - const ids = (data && data.__children) || []; - return ids.map(id => _getOrCreateInstance(id)).filter(Boolean); - } - if (prop === 'descendants') { - return () => { - const out = []; - const stack = [...((_dataIndex[ref] || {}).__children || [])]; - while (stack.length) { - const id = stack.pop(); - const inst = _getOrCreateInstance(id); - if (inst) { - out.push(inst); - const kids = (_dataIndex[id] || {}).__children || []; - for (const k of kids) stack.push(k); - } - } - return out; - }; - } - - // === События === - if (prop === 'changed') { - return { - connect(propName, fn) { - if (typeof fn !== 'function') return; - let evs = _instEvents.get(ref); - if (!evs) { evs = { propChanged: [], destroying: [] }; _instEvents.set(ref, evs); } - evs.propChanged.push({ prop: propName, fn }); - }, - }; - } - if (prop === 'destroying') { - return { - connect(fn) { - if (typeof fn !== 'function') return; - let evs = _instEvents.get(ref); - if (!evs) { evs = { propChanged: [], destroying: [] }; _instEvents.set(ref, evs); } - evs.destroying.push(fn); - }, - }; - } - - // === Методы === - if (prop === 'destroy' || prop === 'delete') return () => _send('scene.delete', { ref }); - if (prop === 'clone') return (offset) => _send('scene.clone', { ref, offset: offset || {} }); - if (prop === 'setAttribute') return (k, v) => _send('scene.setData', { ref, key: k, value: v }); - if (prop === 'getAttribute') return (k) => { - const data = _dataIndex[ref]; - return data ? data[k] : undefined; - }; - if (prop === 'hasTag') return (t) => { - const data = _dataIndex[ref]; - return Array.isArray(data && data.__tags) && data.__tags.includes(t); - }; - if (prop === 'addTag') return (t) => _send('scene.tag', { ref, tag: t }); - if (prop === 'removeTag') return (t) => _send('scene.untag', { ref, tag: t }); - if (prop === 'tween') return (props, opts) => game.tween(ref, props, opts); - if (prop === 'move') return (x, y, z) => _send('scene.move', { ref, x, y, z }); - if (prop === 'setLabel') return (text, o) => _send('scene.setLabel', { ref, text, opts: o || {} }); - if (prop === 'clearLabel') return () => _send('scene.clearLabel', { ref }); - - // === События касания/клика ПРОИЗВОЛЬНОГО объекта === - // findOne('coin').onTouch(fn) — fn() когда игрок коснулся объекта. - // Аналог Roblox part.Touched:Connect. Движок начинает следить за AABB - // объекта (inst.watchTouch) и шлёт instTouch на rising edge. - if (prop === 'onTouch') return (fn) => { - if (typeof fn !== 'function') return; - _instHandlerBucket(ref).touch.push(fn); - _send('inst.watchTouch', { ref }); - }; - if (prop === 'onUntouch') return (fn) => { - if (typeof fn !== 'function') return; - _instHandlerBucket(ref).untouch.push(fn); - _send('inst.watchTouch', { ref }); - }; - if (prop === 'onClick') return (fn) => { - if (typeof fn !== 'function') return; - _instHandlerBucket(ref).click.push(fn); - _send('inst.watchClick', { ref }); - }; - - return undefined; - }, - set(t, prop, value) { - if (prop === 'name') { - _send('inst.set', { ref, prop: 'name', value: String(value) }); - return true; - } - if (prop === 'parent') { - const parentRef = value && typeof value === 'object' - ? (value.ref || value.toString()) - : (value || null); - _send('inst.setParent', { ref, parentRef }); - return true; - } - if (prop === 'position') { - if (value && typeof value === 'object') { - const x = Number(value.x), y = Number(value.y), z = Number(value.z); - if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) { - _send('scene.move', { ref, x, y, z }); - } - } - return true; - } - if (prop === 'color') { - _send('scene.setColor', { ref, color: String(value) }); - return true; - } - if (prop === 'transparency' || prop === 'opacity') { - const v = Number(value); - if (Number.isFinite(v)) { - const op = prop === 'transparency' ? (1 - v) : v; - _send('scene.setOpacity', { ref, value: op }); - } - return true; - } - if (prop === 'visible') { - _send('scene.setVisible', { ref, visible: !!value }); - return true; - } - if (prop === 'canCollide') { - _send('scene.setCollide', { ref, collide: !!value }); - return true; - } - if (prop === 'material') { - _send('scene.setMaterial', { ref, name: String(value) }); - return true; - } - return true; - }, - }); - - _instCache.set(ref, proxy); - return proxy; -} - -/** Триггер событий изменения свойства (вызывается при дельте snapshot'а). */ -function _emitInstChange(ref, prop, newVal, oldVal) { - const evs = _instEvents.get(ref); - if (!evs) return; - for (const rec of evs.propChanged) { - if (rec.prop === prop) { - _safeCall2(rec.fn, [newVal, oldVal], 'inst.changed:' + prop); - } - } -} - -/** Триггер destroying — объект больше не в snapshot. */ -function _emitInstDestroying(ref) { - const evs = _instEvents.get(ref); - if (evs) { - for (const fn of evs.destroying) { - _safeCall2(fn, [], 'inst.destroying'); - } - } - _instEvents.delete(ref); - _instCache.delete(ref); - _instLastValues.delete(ref); -} - -/** - * Сравнение нового snapshot со старым — детект дельт для событий. - * Вызывается из обработчика 'sceneSnapshot' ПОСЛЕ обновления _sceneIndex. - */ -function _detectSnapshotDeltas() { - const live = new Set(); - for (const arr of [_sceneIndex.blocks, _sceneIndex.models, _sceneIndex.primitives]) { - for (const o of arr) { - live.add(o.ref); - if (!_instCache.has(o.ref)) continue; - const prev = _instLastValues.get(o.ref) || {}; - if (prev.x !== undefined && (prev.x !== o.x || prev.y !== o.y || prev.z !== o.z)) { - _emitInstChange(o.ref, 'position', - { x: o.x, y: o.y, z: o.z }, - { x: prev.x, y: prev.y, z: prev.z }); - } - if (prev.name !== undefined && prev.name !== o.name) { - _emitInstChange(o.ref, 'name', o.name, prev.name); - } - _instLastValues.set(o.ref, { x: o.x, y: o.y, z: o.z, name: o.name }); - } - } - for (const ref of [..._instCache.keys()]) { - if (live.has(ref)) continue; - const inst = _instCache.get(ref); - if (inst && inst.kind === 'primitive') { - _emitInstDestroying(ref); - } - } -} - -// Модули (game.require). Код всех скриптов-модулей приходит при init. -// _moduleCode — { 'имя': 'код модуля' } -// _moduleCache — { 'имя': exports } — кеш исполненных модулей -let _moduleCode = {}; -let _moduleCache = {}; - -// Утилиты безопасной отправки в main -const _send = (cmd, payload) => { - try { postMessage({ cmd, payload }); } catch (e) {} -}; - -// Нормализация ref: строка → она сама; Instance-прокси → поле .ref; -// иначе null. Нужно чтобы billboard.set/update/onClick принимали и -// строковый ref ('primitive:NN'), и объект, у которого есть .ref. -function _normRef(ref) { - if (typeof ref === 'string') return ref || null; - if (ref && typeof ref === 'object') { - if (typeof ref.ref === 'string' && ref.ref) return ref.ref; - const s = String(ref); - return s && s !== '[object Object]' ? s : null; - } - return null; -} - -const _safeCall = (fn, arg, where) => { - try { fn(arg); } - catch (err) { - _send('log', { - level: 'error', - text: 'Ошибка в ' + where + ': ' + (err && err.message ? err.message : err), - }); - } -}; - -// Внутренний хелпер: запланировать удаление объекта через seconds секунд. -// Используется для lifetime в scene.spawn и для scene.deleteAfter. -const _scheduleDelete = (ref, seconds) => { - const s = Number(seconds); - if (typeof ref !== 'string' || !ref || !Number.isFinite(s) || s < 0) return; - _timers.push({ - id: ++_timerSeq, - fn: () => _send('scene.delete', { ref }), - delay: s, elapsed: 0, repeat: false, - }); -}; - -// === Публичное API game.* === - -// Объект self создаётся ПОСЛЕ получения init с target. -// Если target нет (глобальный скрипт), game.self === null. -let _selfApi = null; - -function _buildSelfApi() { - if (!_target) return null; - const isGui = _target.kind === 'gui'; - const api = { - get kind() { return _target.kind; }, - // ref — строка формата 'primitive:N' / 'model:N' / 'block:x,y,z', - // единая со scene.find/all и пригодная для scene.* / physics.* / tween. - get ref() { - const k = _target.kind; - if (k === 'primitive' || k === 'model' || k === 'userModel') { - const id = _target.id ?? _target.ref; - return id != null ? k + ':' + id : null; - } - if (k === 'block') { - const r = _target.ref || _target; - if (r && r.x != null) return 'block:' + r.x + ',' + r.y + ',' + r.z; - return null; - } - return _target.ref ?? _target.id ?? null; - }, - get position() { return { ..._selfPosition }; }, - /** - * Свойства GUI-элемента (только для скриптов с target.kind='gui'): - * game.self.props — текущие свойства (имя, текст, x/y/w/h, цвет...) - */ - get props() { - if (!isGui) return null; - const id = _target.id ?? _target.ref; - const found = _guiIndex.find(g => g.id === id); - return found ? { ...found } : null; - }, - onClick(fn) { - if (typeof fn === 'function') _selfClickHandlers.push(fn); - }, - onTouch(fn) { - if (typeof fn === 'function') _selfTouchHandlers.push(fn); - }, - /** Игрок ВЫШЕЛ из объекта (был внутри AABB и вышел). Полезно для триггер-зон. */ - onUntouch(fn) { - if (typeof fn === 'function') _selfUntouchHandlers.push(fn); - }, - /** - * Взаимодействие по клавише E. Когда игрок подходит близко к объекту — - * над объектом появляется подсказка «[E] ...», по нажатию E срабатывает fn. - * game.self.onInteract(() => { - * game.ui.showText('Дверь открыта!'); - * }, { text: 'Открыть дверь', distance: 4 }); - * opts: { text: 'Взаимодействовать', distance: 4 (метры), key: 'e' }. - */ - onInteract(fn, opts) { - if (typeof fn !== 'function') return; - _selfInteractHandlers.push(fn); - // регистрируем объект как интерактивный — main покажет подсказку - _send('self.registerInteract', { - target: _target, - text: (opts && typeof opts.text === 'string') ? opts.text : 'Взаимодействовать', - distance: (opts && Number.isFinite(opts.distance)) ? opts.distance : 4, - key: (opts && typeof opts.key === 'string') ? opts.key.toLowerCase() : 'e', - }); - }, - move(x, y, z) { - const nx = Number(x), ny = Number(y), nz = Number(z); - if (Number.isFinite(nx) && Number.isFinite(ny) && Number.isFinite(nz)) { - _send('self.move', { target: _target, x: nx, y: ny, z: nz }); - } - }, - delete() { - _send('self.delete', { target: _target }); - }, - /** - * Изменить свойства GUI-элемента (только для target.kind='gui'). - * game.self.update({ text: 'Новый текст', textColor: '#ff0000' }); - */ - update(patch) { - if (!isGui || !patch || typeof patch !== 'object') return; - const id = _target.id ?? _target.ref; - _send('gui.update', { id, patch }); - }, - /** Шорткат для смены текста (для Text/Button). */ - setText(text) { - if (!isGui) return; - const id = _target.id ?? _target.ref; - _send('gui.update', { id, patch: { text: String(text == null ? '' : text) } }); - }, - /** Сделать элемент видимым. */ - show() { - if (!isGui) return; - const id = _target.id ?? _target.ref; - _send('gui.update', { id, patch: { visible: true } }); - }, - /** Скрыть элемент. */ - hide() { - if (!isGui) return; - const id = _target.id ?? _target.ref; - _send('gui.update', { id, patch: { visible: false } }); - }, - }; - return api; -} - -const game = { - player: { - /** Позиция «низа ног» игрока. */ - get position() { return { ..._playerState.position }; }, - /** - * Угол поворота игрока вокруг Y (в радианах). - * 0 = смотрит в +Z, π/2 = +X, π = -Z, -π/2 = -X. - */ - get yaw() { return _playerState.yaw || 0; }, - /** Наклон вверх/вниз (в радианах). >0 = смотрит вверх. */ - get pitch() { return _playerState.pitch || 0; }, - /** - * Нормализованный вектор взгляда (направление куда смотрит игрок). - * Удобно использовать для спавна объектов «перед собой»: - * const f = game.player.forward; - * game.scene.spawn('block:grass', { x: p.x + f.x*3, y: p.y, z: p.z + f.z*3 }); - */ - get forward() { return { ..._playerState.forward }; }, - /** - * Команда локального игрока (Фаза 4.4) — имя команды или null. - * Назначается через game.player.setTeam('Красные'). - */ - get team() { - return (_players.me && _players.me.team) || null; - }, - /** - * Прицел в Play: 'none' | 'dot' | 'cross' | 'circle'. - * Чтение возвращает текущее значение, запись — меняет в рантайме: - * game.player.crosshair = 'cross'; - */ - get crosshair() { return _playerState.crosshair || 'none'; }, - set crosshair(v) { - const allowed = ['none', 'dot', 'cross', 'circle']; - const s = String(v || 'none').toLowerCase(); - if (!allowed.includes(s)) return; - _playerState.crosshair = s; - _send('player.crosshair', { type: s }); - }, - teleport(x, y, z) { - const nx = Number(x), ny = Number(y), nz = Number(z); - if (Number.isFinite(nx) && Number.isFinite(ny) && Number.isFinite(nz)) { - _send('player.teleport', { x: nx, y: ny, z: nz }); - } - }, - /** Сдвинуть игрока ТОЛЬКО по X (не трогая Z и Y). Для раннеров — - * смена полосы без отмены продвижения autorun. */ - setLaneX(x) { - const nx = Number(x); - if (Number.isFinite(nx)) _send('player.setLaneX', { x: nx }); - }, - /** Развернуть модель игрока на угол yaw (радианы). Для кат-сцен, - * где игрок стоит лицом в нужную сторону. yaw=0 — лицом в +Z. */ - setFacing(yaw) { - const y = Number(yaw); - if (Number.isFinite(y)) _send('player.setFacing', { yaw: y }); - }, - /** Проиграть эмоцию персонажа: 'wave'|'dance'|'cheer'|'sit'|'paint'. - * Работает только для R15-скинов. Разовая анимация поверх движения. */ - playEmote(name) { - if (typeof name === 'string') _send('player.emote', { name }); - }, - /** Прервать текущую эмоцию персонажа. */ - stopEmote() { - _send('player.stopEmote', {}); - }, - /** Текущее HP игрока (зеркало из main thread). */ - get hp() { return _playerState.hp ?? 100; }, - get maxHp() { return _playerState.maxHp ?? 100; }, - /** Текущее направление гравитации в Кубикон Dash (+1 вниз, -1 вверх). */ - get gravityDir() { return _playerState.gravityDir ?? 1; }, - /** Жив ли игрок (hp > 0). */ - get alive() { return (_playerState.hp ?? 100) > 0; }, - /** - * Состояние игрока: 'ground' (на земле), 'air' (в воздухе/прыжке), - * 'water' (в воде). - * if (game.player.state === 'air') { ... } - */ - get state() { return _playerState.state || 'ground'; }, - /** - * Зажата ли клавиша ПРЯМО СЕЙЧАС (для плавного движения по удержанию). - * key — 'w','a','s','d','space','shift','arrowup'... (lowercase). - * game.onTick(() => { if (game.player.isKeyDown('w')) { ... } }); - */ - isKeyDown(key) { - if (typeof key !== 'string') return false; - return !!_playerState.keys[key.toLowerCase()]; - }, - /** - * Нанести урон игроку. Учитываются i-frames (повторный вызов - * в течение ~0.5с проигнорится). - */ - damage(amount) { - const a = Number(amount); - if (!Number.isFinite(a) || a <= 0) return; - _send('player.damage', { amount: a }); - }, - /** Мгновенно убить игрока (игнорит i-frames). */ - kill() { - _send('player.damage', { amount: 99999 }); - }, - /** Восстановить здоровье. */ - heal(amount) { - const a = Number(amount); - if (!Number.isFinite(a) || a <= 0) return; - _send('player.heal', { amount: a }); - }, - /** - * Вернуть игрока на spawn-point с полным HP. - * Если spawnPoint в проекте задан — телепортирует туда. - */ - respawn() { - _send('player.respawn', null); - }, - /** - * Множитель скорости передвижения. 1 = норма, 1.5 = +50%, 0.5 = вдвое медленнее. - */ - setSpeed(mul) { - const m = Number(mul); - if (Number.isFinite(m) && m > 0) _send('player.setSpeed', { mul: m }); - }, - /** - * Множитель силы прыжка. 1 = норма, 1.5 = выше, 2 = ещё выше. - */ - setJumpPower(mul) { - const m = Number(mul); - if (Number.isFinite(m) && m > 0) _send('player.setJumpPower', { mul: m }); - }, - /** - * Подфаза 3.11 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md. - * Надеть на игрока аксессуар (шляпа/инструмент/причёска/лицо) из - * каталога Рублокса. itemId — числовой id из rublox_items - * (только published — драфты дизайнеров не видны). - * Пример: game.player.equipAccessory(42); // надеть шляпу id=42 - */ - equipAccessory(itemId) { - const id = Number(itemId); - if (Number.isFinite(id) && id > 0) { - _send('player.equipAccessory', { itemId: id }); - } - }, - /** - * Снять аксессуар из слота: 'hat'|'tool'|'tool_left'|'hair'|'face'. - * Пример: game.player.unequipSlot('hat'); - */ - unequipSlot(slot) { - const s = String(slot || '').trim(); - if (s) _send('player.unequipSlot', { slot: s }); - }, - /** Снять все аксессуары. */ - unequipAll() { - _send('player.unequipAll', {}); - }, - /** - * Множитель гравитации. 1 = норма (-22 м/с²), 1.23 = GD-стиль (-27 м/с²). - * Работает в обоих направлениях gravityDir. - */ - setGravityMul(mul) { - const m = Number(mul); - if (Number.isFinite(m) && m > 0) _send('player.setGravityMul', { mul: m }); - }, - /** - * GD-гейммод Ship: тап-удержание = подъём (вертолёт-стиль). - * Обычный jump-импульс отключается, корабль управляется только Space. - */ - setShipMode(enabled) { - _send('player.setShipMode', { enabled: !!enabled }); - }, - /** - * GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе - * (даже без касания земли). Обычный прыжок отключается. - */ - setUfoMode(enabled) { - _send('player.setUfoMode', { enabled: !!enabled }); - }, - /** - * GD-гейммод Wave: движение жёстко под ±45°. - * Space зажат → vy = +autoRunSpeed; отпущен → vy = -autoRunSpeed. - * Гравитация и прыжок отключены. - */ - setWaveMode(enabled) { - _send('player.setWaveMode', { enabled: !!enabled }); - }, - /** - * GD-гейммод Robot: высота прыжка зависит от длительности удержания Space. - * Тап = низкий прыжок (~1.5м), удержание 0.35с = высокий (~4м). Прыжок только с земли. - */ - setRobotMode(enabled) { - _send('player.setRobotMode', { enabled: !!enabled }); - }, - /** - * Двойной прыжок (true/false) — второй прыжок в воздухе. - */ - setDoubleJump(enabled) { - _send('player.setDoubleJump', { enabled: !!enabled }); - }, - /** - * Проиграть эмоцию-анимацию персонажа один раз. - * name: 'wave' (помахать) | 'dance' (танец) | 'cheer' (радость) | 'sit' (сесть). - * game.onKey('e', () => game.player.playAnimation('wave')); - */ - playAnimation(name) { - if (typeof name !== 'string') return; - _send('player.playAnimation', { name }); - }, - /** Прервать текущую эмоцию персонажа. */ - stopAnimation() { - _send('player.stopAnimation', {}); - }, - /** - * Скользкость поверхности под игроком. 0 = нормальное движение - * (мгновенная остановка), 0.85 = «лёд» (скользит после отпускания - * клавиш). 1 = полностью скользко (инерция бесконечная). - */ - setIceFriction(value) { - const v = Number(value); - if (Number.isFinite(v)) { - _send('player.setIceFriction', { value: v }); - } - }, - /** - * Кубикон Dash: авто-бег по +X со скоростью speed (м/с). - * Передай 0 чтобы отключить. Работает только в sideview-камере — - * иначе скрипт сразу после autoRun() должен звать setCameraMode('sideview'). - * Пример: game.player.setAutoRun(8); game.player.setCameraMode('sideview'); - */ - setAutoRun(speed) { - const s = Number(speed); - if (Number.isFinite(s)) _send('player.setAutoRun', { speed: s }); - }, - /** - * Мгновенный подброс игрока вверх. strength=1 = обычный прыжок, - * strength=2 = в 2 раза выше. Не требует Space — срабатывает сразу. - * Используется для трамплинов в Кубикон Dash. - */ - boostJump(strength) { - const s = Number(strength); - if (Number.isFinite(s) && s > 0) _send('player.boostJump', { strength: s }); - }, - /** - * Кубикон Dash: перевернуть гравитацию (как blue orb / gravity portal в GD). - * После flipGravity игрок прыгает к потолку. Повторный вызов возвращает. - * Доступно только в sideview-режиме. - */ - flipGravity() { - _send('player.flipGravity', {}); - }, - /** - * Задать вертикальную скорость игрока (м/с). +значение = вверх, - = вниз. - * Используется для трамплинов (vy=16), jump orb (vy=14), boost-зон и т.д. - * Не зависит от _shipMode/_waveMode/_robotMode — просто перезаписывает _vy. - */ - setVy(vy) { - const v = Number(vy); - if (Number.isFinite(v)) _send('player.setVy', { vy: v }); - }, - /** - * Явно установить направление гравитации: 1 = вниз (норма), -1 = вверх. - */ - setGravityDir(dir) { - const d = Number(dir); - if (d === 1 || d === -1) _send('player.setGravityDir', { dir: d }); - }, - /** - * Показать/скрыть основной скин игрока. Используется в Кубикон Dash: - * скрываем человечка, рисуем куб-примитив через скрипт. - */ - setSkinVisible(visible) { - _send('player.setSkinVisible', { visible: !!visible }); - }, - /** - * === Задача 07: скины игрока (любая 3D-модель + магазин) === - * Сменить активный скин в Play (без перезагрузки сцены). - * game.player.setSkin('squirrel-donut'); // встроенный - * game.player.setSkin('character-a'); // человек - * Возвращает «локальный Promise» (объект с .then) — реальная смена - * асинхронна (грузится .glb). Для большинства игр можно не ждать. - */ - setSkin(slug) { - if (typeof slug !== 'string' || !slug) return; - _currentSkin = slug; - if (_unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); - _send('player.setSkin', { slug }); - }, - /** Дать игроку скин (разблокировать — например после покупки). */ - unlockSkin(slug) { - if (typeof slug !== 'string' || !slug) return; - if (_unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); - _send('player.unlockSkin', { slug }); - }, - /** Список slug'ов скинов, доступных игроку (разблокированных). */ - getAvailableSkins() { - return _unlockedSkins.slice(); - }, - /** Все встроенные скины с метаданными: [{slug,name,kind,category,price}]. */ - getAllSkins() { - return _skinsIndex.map(s => ({ ...s })); - }, - /** Текущий активный скин (slug). */ - getCurrentSkin() { - return _currentSkin; - }, - /** Подписка на смену скина: fn(slug). */ - onSkinChange(fn) { - if (typeof fn === 'function') _skinChangeHandlers.push(fn); - }, - /** Открыть встроенный GUI-магазин скинов (если включён в проекте). */ - openSkinShop() { - _send('player.openSkinShop', {}); - }, - /** Закрыть магазин скинов. */ - closeSkinShop() { - _send('player.closeSkinShop', {}); - }, - /** Игровая валюта магазина скинов (рублики проекта, ЛОКАЛЬНЫЕ — - * не путать с серверной экономикой game.economy). */ - getSkinCoins() { - return _skinCoins; - }, - /** Задать баланс валюты магазина (например стартовые 200). */ - setSkinCoins(amount) { - const n = Number(amount); - if (!Number.isFinite(n)) return; - _skinCoins = Math.max(0, Math.floor(n)); - _send('player.setSkinCoins', { amount: _skinCoins }); - }, - /** Добавить валюту магазина (награда за что-то). */ - addSkinCoins(amount) { - const n = Number(amount); - if (!Number.isFinite(n)) return; - _skinCoins = Math.max(0, _skinCoins + Math.floor(n)); - _send('player.setSkinCoins', { amount: _skinCoins }); - }, - /** - * Режим камеры: 'first' | 'third' | 'front' | 'sideview'. - * 'sideview' нужен для Кубикон Dash — камера фиксируется сбоку, - * yaw/pitch от мыши/тача игнорируются. - */ - setCameraMode(mode) { - if (typeof mode !== 'string') return; - _send('player.setCameraMode', { mode }); - }, - /** Задача 02: установить дистанцию камеры (для third-person). */ - setCameraZoom(distance) { - const d = Number(distance); - if (!Number.isFinite(d)) return; - _send('player.setCameraZoom', { distance: d }); - }, - /** Задача 02: границы зума колесом мыши. По умолчанию 0.5..32. */ - setCameraZoomLimits(min, max) { - const mn = Number(min), mx = Number(max); - if (!Number.isFinite(mn) || !Number.isFinite(mx)) return; - _send('player.setCameraZoomLimits', { min: mn, max: mx }); - }, - /** Задача 02: Shift-Lock — курсор в центре, корпус лицом к камере. */ - setShiftLock(on) { - _send('player.setShiftLock', { on: !!on }); - }, - /** - * Присед: уменьшает высоту хитбокса игрока с 1.8 до 0.9 ед. - * Используется чтобы пройти под низким потолком. - */ - setCrouch(enabled) { - _send('player.setCrouch', { enabled: !!enabled }); - }, - /** - * Назначить активную точку возрождения. При respawn / смерти - * игрок появится здесь. Аргумент: - * - ref объекта ('primitive:N' / 'model:N' / 'block:x,y,z') — - * игрок встанет НАД этим объектом; - * - объект {x, y, z} — точные координаты. - * game.self.onInteract(() => game.player.setSpawn(game.self.ref)); - */ - setSpawn(target) { - if (typeof target === 'string' && target) { - _send('player.setSpawn', { ref: target }); - } else if (target && typeof target === 'object' - && Number.isFinite(Number(target.x))) { - _send('player.setSpawn', { - x: Number(target.x), - y: Number(target.y), - z: Number(target.z), - }); - } - }, - /** - * Дать игроку инструмент/оружие в инвентарь (Фаза 4.2). - * toolType — id модели ('weapon-sword', 'blaster-blaster-a', ...) - * или произвольное имя предмета. - * opts: { name, equip:true (сразу взять в руки), params }. - * Оружие (blaster-* / weapon-*) получает kind='weapon' + параметры - * боя; прочее — kind='tool'. - * game.player.giveTool('blaster-blaster-a', { equip: true }); - */ - giveTool(toolType, opts) { - if (typeof toolType !== 'string' || !toolType) return; - opts = opts || {}; - const isBlaster = toolType.indexOf('blaster') === 0; - const isMelee = toolType.indexOf('weapon-') === 0; - let kind = 'tool'; - let params = {}; - if (isBlaster) { - kind = 'weapon'; - params = { - damage: 25, fireRate: 0.2, range: 60, - magazine: 12, reserve: 48, - }; - } else if (isMelee) { - kind = 'weapon'; - params = { weaponKind: 'melee', damage: 35, fireRate: 0.6, range: 3 }; - } - // opts.params переопределяет дефолты. - if (opts.params && typeof opts.params === 'object') { - params = { ...params, ...opts.params }; - } - _send('inventory.give', { - kind, - modelTypeId: toolType, - name: typeof opts.name === 'string' ? opts.name : toolType, - params, - equip: opts.equip === true, - }); - }, - /** Убрать инструмент/оружие из инвентаря по id модели или имени. */ - removeTool(toolType) { - if (typeof toolType !== 'string') return; - _send('inventory.remove', { modelTypeId: toolType, name: toolType }); - }, - /** - * Подписка: игрок применил инструмент (ЛКМ с предметом в активном - * слоте). fn получает { tool: {kind, modelTypeId, name}, point, target }. - */ - onToolUse(fn) { - if (typeof fn === 'function') _toolUseHandlers.push(fn); - }, - /** - * Назначить игроку команду (Фаза 4.4). Команда должна быть - * заранее создана через game.teams.create(). null/'' убирает. - * game.teams.create('Красные', '#ff3333'); - * game.player.setTeam('Красные'); - */ - setTeam(name) { - _send('player.setTeam', { team: typeof name === 'string' ? name : null }); - }, - }, - /** - * Таймер прохождения для лидерборда. - * game.timer.start() — запустить отсчёт (с нуля, отображается в HUD). - * game.timer.stop() — остановить (но не отправлять). - * game.timer.submit() — остановить + отправить рекорд в лидерборд. - * Сервер сохраняет если время лучше предыдущего. - */ - timer: { - start() { _send('timer.start', null); }, - stop() { _send('timer.stop', null); }, - submit() { _send('timer.submit', null); }, - }, - get self() { return _selfApi; }, - onTick(fn) { - if (typeof fn === 'function') _tickHandlers.push(fn); - }, - /** - * Выполнить fn ОДИН раз через seconds секунд. - * Возвращает id таймера — его можно отменить через game.cancel(id). - * game.after(3, () => game.ui.showText('Прошло 3 секунды!')); - */ - after(seconds, fn) { - const s = Number(seconds); - if (!Number.isFinite(s) || s < 0 || typeof fn !== 'function') return null; - const id = ++_timerSeq; - _timers.push({ id, fn, delay: s, elapsed: 0, repeat: false }); - return id; - }, - /** - * Выполнять fn КАЖДЫЕ seconds секунд (циклично). - * Возвращает id таймера — остановить через game.cancel(id). - * const t = game.every(1, () => game.log('тик')); - * game.after(10, () => game.cancel(t)); // через 10с остановить - */ - every(seconds, fn) { - const s = Number(seconds); - if (!Number.isFinite(s) || s <= 0 || typeof fn !== 'function') return null; - const id = ++_timerSeq; - _timers.push({ id, fn, delay: s, elapsed: 0, repeat: true }); - return id; - }, - /** Отменить таймер (after или every) по id, который вернул after()/every(). */ - cancel(id) { - if (id == null) return; - const i = _timers.findIndex(t => t.id === id); - if (i >= 0) _timers.splice(i, 1); - }, - /** - * Плавно изменить свойства объекта (твин — анимация перехода). - * ref — объект сцены (то что вернул scene.spawn / scene.find) или GUI-id. - * props — что менять и до какого значения: - * { x, y, z, rotationX, rotationY, rotationZ, sx, sy, sz, - * color: '#ff0000', opacity: 0..1 } - * opts — { duration: 1 (сек), easing: 'linear'|'ease'|'bounce'|'elastic'|'back', - * delay: 0, repeat: 0 (раз; -1 = бесконечно), yoyo: false, - * onDone: fn } - * Возвращает tweenId — анимацию можно прервать через game.cancelTween(id). - * - * // плавно открыть дверь за 1 секунду - * game.tween(door, { rotationY: Math.PI/2 }, { duration: 1, easing: 'ease' }); - * // пульсирующая монетка - * game.tween(coin, { sy: 1.4 }, { duration: 0.5, yoyo: true, repeat: -1 }); - */ - tween(ref, props, opts) { - if (typeof ref !== 'string' || !props || typeof props !== 'object') return null; - opts = opts || {}; - const id = ++_tweenSeq; - if (typeof opts.onDone === 'function') _tweenCallbacks[id] = opts.onDone; - _send('tween.start', { - tweenId: id, - ref, - props, - duration: Number.isFinite(Number(opts.duration)) ? Number(opts.duration) : 1, - easing: typeof opts.easing === 'string' ? opts.easing : 'ease', - delay: Number.isFinite(Number(opts.delay)) ? Number(opts.delay) : 0, - repeat: Number.isFinite(Number(opts.repeat)) ? Number(opts.repeat) : 0, - yoyo: !!opts.yoyo, - }); - return id; - }, - /** Прервать твин по id, который вернул game.tween(). */ - cancelTween(id) { - if (id == null) return; - delete _tweenCallbacks[id]; - _send('tween.cancel', { tweenId: id }); - }, - /** - * Подписаться на нажатие клавиши. - * key — буква 'w', 'a', 's', 'd' или специальные имена 'space', 'shift', - * 'enter', 'escape', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'. - * Сравнение case-insensitive. Если key не передан — fn вызывается на любую клавишу. - */ - onKey(key, fn) { - if (typeof key === 'function') { fn = key; key = '*'; } - if (typeof fn !== 'function') return; - const k = String(key).toLowerCase(); - (_globalKeyDownHandlers[k] = _globalKeyDownHandlers[k] || []).push(fn); - }, - /** То же что onKey, но на отпускание клавиши. */ - onKeyUp(key, fn) { - if (typeof key === 'function') { fn = key; key = '*'; } - if (typeof fn !== 'function') return; - const k = String(key).toLowerCase(); - (_globalKeyUpHandlers[k] = _globalKeyUpHandlers[k] || []).push(fn); - }, - /** - * Глобальный клик в Play-режиме. event = {point, target}. - * target = null если клик прошёл мимо объектов. - */ - onClick(fn) { - if (typeof fn === 'function') _globalClickHandlers.push(fn); - }, - /** - * Игрок коснулся любого объекта (с target-скриптом или без — - * для глобального события событие шлётся ВСЕГДА). - * event = {target}. - */ - onPlayerTouch(fn) { - if (typeof fn === 'function') _globalTouchHandlers.push(fn); - }, - /** - * Моб убит игроком (или другим способом). - * fn({mobType, position}). mobType: 'zombie' | ... - */ - onMobKilled(fn) { - if (typeof fn === 'function') _mobKilledHandlers.push(fn); - }, - /** - * Любой NPC погиб (hp дошёл до 0). fn({id, position}). - * Для конкретного NPC удобнее npc.onDeath(fn) на объекте-NPC. - */ - onNpcDeath(fn) { - if (typeof fn === 'function') _globalNpcDeathHandlers.push(fn); - }, - /** - * Игрок присоединился к комнате (Фаза 4.3). fn({sessionId, name}). - */ - onPlayerJoin(fn) { - if (typeof fn === 'function') _playerJoinHandlers.push(fn); - }, - /** - * Катсцена камеры доиграла (Фаза 5.7). fn() — без аргументов. - * game.camera.cutscene([...]); - * game.onCutsceneDone(() => game.camera.reset()); - */ - onCutsceneDone(fn) { - if (typeof fn === 'function') _cutsceneDoneHandlers.push(fn); - }, - /** Игрок покинул комнату. fn({sessionId, name}). */ - onPlayerLeave(fn) { - if (typeof fn === 'function') _playerLeaveHandlers.push(fn); - }, - /** - * Подписаться на адресное сообщение (Фаза 4.3). fn({from, data}). - * game.onMessage('подарок', (msg) => game.ui.showText('от ' + msg.from)); - */ - onMessage(name, fn) { - if (typeof name !== 'string' || typeof fn !== 'function') return; - (_mpMessageHandlers[name] = _mpMessageHandlers[name] || []).push(fn); - }, - /** - * Отправить сообщение игроку (Фаза 4.3). - * game.sendTo(player, 'подарок', { gold: 100 }); - * player — объект из game.players.* или его sessionId. - */ - sendTo(player, name, data) { - if (typeof name !== 'string') return; - const sessionId = typeof player === 'string' - ? player - : (player && player.sessionId); - if (!sessionId) return; - _send('mp.sendTo', { sessionId, name, data }); - }, - /** - * Подписаться на изменение HP игрока (получение урона / лечение / смерть). - * fn(event) где event = { hp, maxHp, source, damaged, delta }. - * - source — строка ('script', 'zombie', 'fall', 'lava', ...) или null. - * - delta — изменение HP (отрицательное = урон, положительное = лечение). - * - damaged — true если это был урон. - */ - onHpChange(fn) { - if (typeof fn === 'function') _hpChangeHandlers.push(fn); - }, - /** - * Игрок погиб (hp дошло до 0). Срабатывает один раз на смерть. - * game.onPlayerDied(() => game.ui.showText('Игра окончена', 3)); - */ - onPlayerDied(fn) { - if (typeof fn === 'function') _playerDiedHandlers.push(fn); - }, - /** Игрок прыгнул. */ - onPlayerJump(fn) { - if (typeof fn === 'function') _playerJumpHandlers.push(fn); - }, - /** Игрок приземлился (коснулся земли после полёта/прыжка). */ - onPlayerLand(fn) { - if (typeof fn === 'function') _playerLandHandlers.push(fn); - }, - /** - * UI / HUD — текст и счётчики поверх viewport в Play. - * game.ui.showText('Привет', 2) — флешит текст в центре - * game.ui.score = 100 — счётчик в углу - * game.ui.timer = 60 — таймер - * game.ui.set('hp', 'HP: 100', {color}) — произвольная именованная метка - * game.ui.remove('hp') - * game.ui.clear() — убрать всё - */ - ui: (() => { - const _state = { score: null, timer: null }; - return { - get score() { return _state.score; }, - set score(v) { _state.score = v; _send('ui.set', { id: '__score', text: v == null ? null : 'Очки: ' + v }); }, - get timer() { return _state.timer; }, - set timer(v) { - _state.timer = v; - if (v == null) { _send('ui.set', { id: '__timer', text: null }); return; } - const n = Number(v); - if (!Number.isFinite(n)) return; - const mm = Math.floor(Math.max(0, n) / 60); - const ss = Math.floor(Math.max(0, n) % 60); - const txt = (mm < 10 ? '0' : '') + mm + ':' + (ss < 10 ? '0' : '') + ss; - _send('ui.set', { id: '__timer', text: txt }); - }, - /** Кратковременный текст по центру экрана. seconds=2 по умолчанию. */ - showText(text, seconds) { - _send('ui.flash', { - text: String(text == null ? '' : text), - seconds: Number.isFinite(Number(seconds)) ? Number(seconds) : 2, - }); - }, - /** - * Установить произвольную метку. - * id — уникальное имя для последующих обновлений и remove. - * opts: { x, y } — позиция в процентах (0..100), { color, size } — стилизация. - */ - set(id, text, opts) { - if (typeof id !== 'string' || !id) return; - _send('ui.set', { - id, - text: text == null ? null : String(text), - opts: opts || null, - }); - }, - /** Убрать метку по id. */ - remove(id) { - if (typeof id !== 'string' || !id) return; - _send('ui.set', { id, text: null }); - }, - /** Убрать весь HUD. */ - clear() { - _state.score = null; - _state.timer = null; - _send('ui.clear', null); - }, - }; - })(), - /** API сцены: spawn/delete/find/all. */ - scene: { - /** - * Создать объект на сцене. - * type: 'block:' / 'primitive:' / 'model:' / 'light:point'. - * opts: { x, y, z, sx, sy, sz, color, material, rotationY, name, lifetime, - * brightness, range }. - * lifetime — если задан (секунды), объект сам удалится через это время. - * brightness/range — только для 'light:point' (яркость и радиус лампы). - * Возвращает строку-ref (можно использовать в delete/getPosition). - */ - spawn(type, opts) { - if (typeof type !== 'string') return null; - opts = opts || {}; - // Алиас: 'light:point' — это примитив-лампа. - if (type === 'light:point' || type === 'light') type = 'primitive:light'; - const x = Number(opts.x) || 0; - const y = Number(opts.y) || 0; - const z = Number(opts.z) || 0; - const colon = type.indexOf(':'); - if (colon < 0) return null; - const kind = type.slice(0, colon); - const subType = type.slice(colon + 1); - if (kind === 'block') { - const ix = Math.round(x), iy = Math.round(y), iz = Math.round(z); - const ref = 'block:' + ix + ',' + iy + ',' + iz; - // color — для окрашиваемых блоков (studs-block, задача 09). - _send('scene.spawn', { kind: 'block', subType, x: ix, y: iy, z: iz, ref, color: opts.color }); - if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime); - return ref; - } - if (kind === 'primitive' || kind === 'model') { - _localRefSeq++; - const ref = kind + ':_local_' + _localRefSeq; - _send('scene.spawn', { - kind, subType, x, y, z, - sx: opts.sx, sy: opts.sy, sz: opts.sz, - color: opts.color, material: opts.material, - rotationY: opts.rotationY, - name: opts.name, - brightness: opts.brightness, range: opts.range, - effect: opts.effect, - // anchored:false → объект падает (физика). По умолчанию - // примитив заякорен (anchored:true) и висит на месте. - anchored: opts.anchored, - // canCollide — можно сделать объект проходимым (зона). - canCollide: opts.canCollide, - // visible:false → объект скрыт (показать через setVisible). - visible: opts.visible, - // textureAsset — id картинки из ассетов проекта на грани. - textureAsset: opts.textureAsset, - ref, - }); - if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime); - return ref; - } - // Пользовательская модель из воксельного редактора моделей. - // type = 'user:', где — числовой id модели в проекте. - // ref пользовательских инстансов в сцене — 'usermodel:'. - if (kind === 'user') { - _localRefSeq++; - const ref = 'usermodel:_local_' + _localRefSeq; - _send('scene.spawn', { - kind: 'userModel', - // subType — это полная строка 'user:' (как принимает - // UserModelManager.addInstance). Восстанавливаем её. - subType: 'user:' + subType, - x, y, z, - rotationY: opts.rotationY, - name: opts.name, - ref, - }); - if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime); - return ref; - } - return null; - }, - /** Удалить объект по ref. */ - delete(ref) { - if (typeof ref !== 'string' || !ref) return; - _send('scene.delete', { ref }); - }, - /** - * Удалить объект через seconds секунд (авто-удаление). - * const p = game.scene.spawn('primitive:cube', { x, y, z }); - * game.scene.deleteAfter(p, 5); // исчезнет через 5 секунд - */ - deleteAfter(ref, seconds) { - _scheduleDelete(ref, seconds); - }, - /** - * Переместить объект (для моделей/примитивов) — без target-скрипта. - * ref — то что вернул spawn() или scene.find(). - */ - move(ref, x, y, z) { - if (typeof ref !== 'string') return; - const nx = Number(x), ny = Number(y), nz = Number(z); - if (!Number.isFinite(nx) || !Number.isFinite(ny) || !Number.isFinite(nz)) return; - // Парсим ref: 'primitive:_local_3' или 'primitive:realId' или 'model:id' - const colon = ref.indexOf(':'); - if (colon < 0) return; - const kind = ref.slice(0, colon); - const id = ref.slice(colon + 1); - if (kind !== 'primitive' && kind !== 'model') return; - _send('self.move', { - target: { kind, id, ref: id }, - x: nx, y: ny, z: nz, - }); - }, - /** - * Повернуть объект вокруг Y (в радианах). Только для примитивов. - */ - rotate(ref, ry) { - if (typeof ref !== 'string') return; - const r = Number(ry); - if (!Number.isFinite(r)) return; - const colon = ref.indexOf(':'); - if (colon < 0) return; - const kind = ref.slice(0, colon); - const id = ref.slice(colon + 1); - if (kind !== 'primitive') return; - _send('scene.rotate', { kind, id, rotationY: r }); - }, - /** - * Установить полный поворот (rx, ry, rz) в радианах. Для примитивов. - * Нужно для Кубикон Dash: куб крутится вокруг Z в воздухе. - */ - setRotation(ref, rx, ry, rz) { - if (typeof ref !== 'string') return; - const x = Number(rx), y = Number(ry), z = Number(rz); - if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return; - const colon = ref.indexOf(':'); - if (colon < 0) return; - const kind = ref.slice(0, colon); - const id = ref.slice(colon + 1); - if (kind !== 'primitive') return; - _send('scene.setRotation', { id, rx: x, ry: y, rz: z }); - }, - /** - * Изменить collision примитива (true = твёрдый, false = проваливается). - */ - setCollide(ref, can) { - if (typeof ref !== 'string') return; - const colon = ref.indexOf(':'); - if (colon < 0) return; - const kind = ref.slice(0, colon); - const id = ref.slice(colon + 1); - if (kind !== 'primitive') return; - _send('scene.setCollide', { id, canCollide: !!can }); - }, - /** - * Изменить видимость примитива/модели (true = видно, false = скрыт). - */ - setVisible(ref, vis) { - if (typeof ref !== 'string') return; - const colon = ref.indexOf(':'); - if (colon < 0) return; - const kind = ref.slice(0, colon); - const id = ref.slice(colon + 1); - if (kind !== 'primitive' && kind !== 'model') return; - _send('scene.setVisible', { kind, id, visible: !!vis }); - }, - /** - * Изменить цвет примитива (hex-строка типа '#ff0000'). - */ - setColor(ref, color) { - if (typeof ref !== 'string' || typeof color !== 'string') return; - const colon = ref.indexOf(':'); - if (colon < 0) return; - const kind = ref.slice(0, colon); - const id = ref.slice(colon + 1); - if (kind !== 'primitive') return; - _send('scene.setColor', { id, color }); - }, - /** - * Повесить текст-метку НАД объектом (имя/HP над персонажем, врагом). - * Метка всегда повёрнута к камере и видна поверх геометрии. - * game.scene.setLabel(enemy, 'Босс HP: 100', { color: '#ff4444' }); - * opts: { color: '#fff', height: 2.5 (м над объектом), size: 1 }. - * Работает для примитивов и моделей. - */ - setLabel(ref, text, opts) { - if (typeof ref !== 'string') return; - _send('scene.setLabel', { - ref, - text: String(text == null ? '' : text), - opts: opts || {}, - }); - }, - /** Убрать метку с объекта. */ - clearLabel(ref) { - if (typeof ref !== 'string') return; - _send('scene.clearLabel', { ref }); - }, - /** - * Прозрачность примитива: 1 = непрозрачно, 0 = полностью невидимо. - * Для плавного исчезновения используй game.tween(ref, {opacity: 0}, ...). - */ - setOpacity(ref, value) { - if (typeof ref !== 'string') return; - const v = Number(value); - if (!Number.isFinite(v)) return; - if (ref.indexOf('primitive:') !== 0) return; - // шлём ПОЛНЫЙ ref — GameRuntime._resolvePrimitiveId резолвит - // локальный ref ('primitive:_local_N') через _localToReal. - _send('scene.setOpacity', { ref, opacity: Math.max(0, Math.min(1, v)) }); - }, - /** - * Масштаб примитива по осям. 1 = обычный размер, 2 = вдвое больше. - * Можно передать одно число (одинаково по всем осям) или три. - * game.scene.setScale(box, 2); // куб ×2 - * game.scene.setScale(box, 1, 3, 1); // вытянуть по Y - */ - setScale(ref, sx, sy, sz) { - if (typeof ref !== 'string') return; - let nx = Number(sx); - if (!Number.isFinite(nx) || nx <= 0) return; - let ny = Number(sy), nz = Number(sz); - // один аргумент → одинаково по всем осям - if (!Number.isFinite(ny)) ny = nx; - if (!Number.isFinite(nz)) nz = nx; - if (ref.indexOf('primitive:') !== 0) return; - _send('scene.setScale', { ref, sx: nx, sy: ny, sz: nz }); - }, - /** - * Материал примитива: 'default' | 'metal' | 'glass' | 'neon'. - * game.scene.setMaterial(box, 'neon'); // куб светится - */ - setMaterial(ref, name) { - if (typeof ref !== 'string' || typeof name !== 'string') return; - if (ref.indexOf('primitive:') !== 0) return; - _send('scene.setMaterial', { ref, material: name }); - }, - /** - * Создать копию примитива со смещением. Возвращает ref новой копии. - * const copy = game.scene.clone(box, { dx: 3 }); // копия на 3 правее - * offset: { dx, dy, dz } — смещение относительно оригинала. - */ - clone(ref, offset) { - if (typeof ref !== 'string') return null; - if (ref.indexOf('primitive:') !== 0) return null; - offset = offset || {}; - _localRefSeq++; - const newRef = 'primitive:_local_' + _localRefSeq; - _send('scene.clone', { - ref, - newRef, - dx: Number(offset.dx) || 0, - dy: Number(offset.dy) || 0, - dz: Number(offset.dz) || 0, - }); - return newRef; - }, - /** - * Установить динамическую текстуру примитива из dataURL. - * dataUrl — base64 PNG (например, из canvas.toDataURL()). - * Используется для GD-скинов: canvas-фабрика рисует лицо куба → шлёт сюда. - */ - setTexture(ref, dataUrl) { - if (typeof ref !== 'string' || typeof dataUrl !== 'string') return; - const colon = ref.indexOf(':'); - if (colon < 0) return; - const kind = ref.slice(0, colon); - const id = ref.slice(colon + 1); - if (kind !== 'primitive') return; - _send('scene.setTexture', { id, dataUrl }); - }, - /** - * Установить АБСОЛЮТНЫЙ угол поворота папки вокруг точки pivot (XZ). - * Все примитивы внутри папки повернутся как единое целое. - * game.scene.setFolderYaw('Голова куклы', Math.PI, { x: 0, z: 90 }); - */ - setFolderYaw(folderName, angle, pivot) { - if (typeof folderName !== 'string') return; - const a = Number(angle); - if (!Number.isFinite(a)) return; - if (!pivot || !Number.isFinite(Number(pivot.x)) - || !Number.isFinite(Number(pivot.z))) return; - _send('scene.setFolderYaw', { - folderName, - angle: a, - pivot: { x: Number(pivot.x), z: Number(pivot.z) }, - }); - }, - /** - * Найти объекты по name. Возвращает массив Instance-прокси (паритет - * со студией). Instance coerces в строку-ref, поэтому код, принимавший - * строковый ref, продолжает работать. - */ - find(name) { - const out = []; - const n = String(name || '').toLowerCase(); - for (const m of _sceneIndex.models) { - if (m.name && String(m.name).toLowerCase() === n) { - out.push(_getOrCreateInstance(m.ref, 'model')); - } - } - for (const p of _sceneIndex.primitives) { - if (p.name && String(p.name).toLowerCase() === n) { - out.push(_getOrCreateInstance(p.ref, 'primitive')); - } - } - return out; - }, - /** Первый объект с таким name или null. */ - findOne(name) { - const arr = this.find(name); - return arr.length > 0 ? arr[0] : null; - }, - /** Список Instance всех объектов заданного типа: 'block' | 'model' | 'primitive'. */ - all(kind) { - if (kind === 'block') return _sceneIndex.blocks.map(b => _getOrCreateInstance(b.ref, 'block')); - if (kind === 'model') return _sceneIndex.models.map(m => _getOrCreateInstance(m.ref, 'model')); - if (kind === 'primitive') return _sceneIndex.primitives.map(p => _getOrCreateInstance(p.ref, 'primitive')); - return []; - }, - /** - * Сохранить произвольное значение НА объекте (атрибут). - * Видно всем скриптам — скрипт двери ставит, скрипт ключа читает. - * game.scene.setData(door, 'locked', true); - * game.scene.setData(chest, 'gold', 100); - */ - setData(ref, key, value) { - if (typeof ref !== 'string' || typeof key !== 'string') return; - // оптимистично обновляем локальное зеркало (до прихода снапшота) - if (!_dataIndex[ref]) _dataIndex[ref] = {}; - _dataIndex[ref][key] = value; - _send('scene.setData', { ref, key, value }); - }, - /** - * Прочитать атрибут объекта. Возвращает значение или undefined. - * if (game.scene.getData(door, 'locked')) { ... } - */ - getData(ref, key) { - if (typeof ref !== 'string' || typeof key !== 'string') return undefined; - const bag = _dataIndex[ref]; - return bag ? bag[key] : undefined; - }, - /** - * Теги объектов (Фаза 5.6) — как CollectionService в Roblox. - * Помечаешь объекты тегом, потом находишь все объекты с тегом. - * game.scene.tag(enemy, 'враг'); - * for (const e of game.scene.getTagged('враг')) { ... } - */ - tag(ref, tag) { - if (typeof ref !== 'string' || typeof tag !== 'string' || !tag) return; - // Оптимистично обновляем локальное зеркало (до прихода снапшота). - if (!_dataIndex[ref]) _dataIndex[ref] = {}; - const cur = Array.isArray(_dataIndex[ref].__tags) ? _dataIndex[ref].__tags : []; - if (!cur.includes(tag)) _dataIndex[ref].__tags = [...cur, tag]; - _send('scene.tag', { ref, tag }); - }, - /** Снять тег с объекта. */ - untag(ref, tag) { - if (typeof ref !== 'string' || typeof tag !== 'string' || !tag) return; - if (_dataIndex[ref] && Array.isArray(_dataIndex[ref].__tags)) { - _dataIndex[ref].__tags = _dataIndex[ref].__tags.filter(t => t !== tag); - } - _send('scene.untag', { ref, tag }); - }, - /** True если у объекта есть такой тег. */ - hasTag(ref, tag) { - if (typeof ref !== 'string' || typeof tag !== 'string') return false; - const bag = _dataIndex[ref]; - return !!(bag && Array.isArray(bag.__tags) && bag.__tags.includes(tag)); - }, - /** Список ref всех объектов с заданным тегом. */ - getTagged(tag) { - if (typeof tag !== 'string' || !tag) return []; - const out = []; - for (const ref of Object.keys(_dataIndex)) { - const bag = _dataIndex[ref]; - if (bag && Array.isArray(bag.__tags) && bag.__tags.includes(tag)) { - out.push(ref); - } - } - return out; - }, - /** Позиция объекта по ref или null. Работает и с локальным ref - * от scene.spawn (резолвит в реальный через _spawnLocalToReal). */ - getPosition(ref) { - if (typeof ref !== 'string') return null; - // Локальный ref scene.spawn → реальный. - const r = _spawnLocalToReal[ref] || ref; - for (const b of _sceneIndex.blocks) if (b.ref === r) return { x: b.x, y: b.y, z: b.z }; - for (const m of _sceneIndex.models) if (m.ref === r) return { x: m.x, y: m.y, z: m.z }; - for (const p of _sceneIndex.primitives) if (p.ref === r) return { x: p.x, y: p.y, z: p.z }; - return null; - }, - /** - * Создать NPC — управляемого скриптом персонажа (Фаза 4.1). - * modelType — id модели (как в game.scene.spawn('model:...')). - * opts: { x, y, z, rotationY, hp, name, speed }. - * Возвращает объект-NPC с методами: - * npc.moveTo(x, z) — идти в точку - * npc.follow(ref) — следовать за объектом ('player' или ref) - * npc.stop() — остановиться - * npc.say(text, sec) — реплика над головой - * npc.damage(amount) — нанести урон - * npc.remove() — убрать со сцены - * npc.onDeath(fn) — колбэк при гибели NPC - * npc.position — {x,y,z} (актуальная позиция) - * npc.hp / npc.name — текущие значения - * npc.ref — строковый ref NPC - * - * const trader = game.scene.spawnNpc('robot', { x: 5, z: 0, name: 'Боб' }); - * trader.say('Привет!'); - * trader.follow('player'); - */ - spawnNpc(modelType, opts) { - if (typeof modelType !== 'string') return null; - opts = opts || {}; - _npcRefSeq++; - const ref = 'npc:_local_' + _npcRefSeq; - _send('npc.spawn', { - modelType, ref, - x: Number(opts.x) || 0, - y: Number(opts.y) || 0, - z: Number(opts.z) || 0, - rotationY: Number(opts.rotationY) || 0, - hp: Number.isFinite(Number(opts.hp)) ? Number(opts.hp) : undefined, - name: typeof opts.name === 'string' ? opts.name : undefined, - speed: Number.isFinite(Number(opts.speed)) ? Number(opts.speed) : undefined, - }); - return _makeNpcProxy(ref); - }, - /** Список всех NPC на сцене — массив объектов {id, name, x,y,z, hp, ...}. */ - npcs() { - return _npcs.map(n => ({ ...n })); - }, - /** - * Эффект частиц в точке. Авто-удаляется через duration секунд. - * type: 'fire' | 'smoke' | 'sparks' | 'magic' | 'explosion' | 'confetti' - * position: {x,y,z} - * options: { duration: 2 (сек), count: 50 (множитель), color: '#ffaa00' } - */ - spawnParticles(type, position, options) { - if (typeof type !== 'string' || !position) return; - _send('scene.particles', { - type, - position: { - x: Number(position.x) || 0, - y: Number(position.y) || 0, - z: Number(position.z) || 0, - }, - duration: options && Number.isFinite(Number(options.duration)) ? Number(options.duration) : 1.5, - count: options && Number.isFinite(Number(options.count)) ? Number(options.count) : 1, - color: options?.color || null, - }); - }, - /** - * Снимок всех живых мобов (зомби и т.д.). Обновляется каждый tick. - * Возвращает массив { id, mobType, x, y, z, hp }. - * Опциональный фильтр: { mobType?: 'zombie', within?: { x, y?, z, radius } } - */ - mobs(filter) { - const arr = _mobs.slice(); - if (!filter) return arr; - const wantType = typeof filter.mobType === 'string' ? filter.mobType : null; - const within = filter.within; - if (wantType == null && !within) return arr; - const out = []; - const wx = within ? Number(within.x) || 0 : 0; - const wz = within ? Number(within.z) || 0 : 0; - const wr2 = within ? (Number(within.radius) || 0) ** 2 : 0; - for (const m of arr) { - if (wantType && m.mobType !== wantType) continue; - if (within) { - const dx = m.x - wx, dz = m.z - wz; - if (dx*dx + dz*dz > wr2) continue; - } - out.push(m); - } - return out; - }, - /** - * Убить моба (или массив мобов). Принимает объект из mobs() или его id. - * Запускает обычную смерть (с эффектами + onMobKilled). - */ - killMob(target) { - if (target == null) return; - const items = Array.isArray(target) ? target : [target]; - for (const it of items) { - let id = null; - if (typeof it === 'number') id = it; - else if (it && typeof it === 'object' && 'id' in it) id = Number(it.id); - if (Number.isFinite(id)) _send('mob.kill', { id }); - } - }, - /** - * Высота поверхности гладкого ландшафта в точке (x, z). - * Билинейная интерполяция по карте высот (raycast по реальному - * мешу, снятой при старте). Нужно чтобы скрипты ставили объекты - * (животных и т.п.) ТОЧНО на землю, а не парили/тонули. - * - * Возвращает Y поверхности, или null если карта высот не пришла - * (нет гладкого ландшафта в проекте). - * - * const y = game.scene.surfaceY(p.x, p.z); - * if (y !== null) game.self.move(nx, y, nz); - */ - surfaceY(x, z) { - const hm = _terrainHM; - if (!hm || !hm.heights) return null; - const nx = Number(x), nz = Number(z); - if (!Number.isFinite(nx) || !Number.isFinite(nz)) return null; - const fx = (nx - hm.origin.x) / hm.step; - const fz = (nz - hm.origin.z) / hm.step; - let c0 = Math.floor(fx), r0 = Math.floor(fz); - // clamp в пределы карты - if (c0 < 0) c0 = 0; if (c0 > hm.cols - 2) c0 = hm.cols - 2; - if (r0 < 0) r0 = 0; if (r0 > hm.rows - 2) r0 = hm.rows - 2; - const tx = Math.max(0, Math.min(1, fx - c0)); - const tz = Math.max(0, Math.min(1, fz - r0)); - const H = hm.heights; - const W = hm.cols; - const h00 = H[r0 * W + c0]; - const h10 = H[r0 * W + c0 + 1]; - const h01 = H[(r0 + 1) * W + c0]; - const h11 = H[(r0 + 1) * W + c0 + 1]; - // null-ячейки заменяем на среднее валидных - const vals = []; - if (h00 != null) vals.push(h00); - if (h10 != null) vals.push(h10); - if (h01 != null) vals.push(h01); - if (h11 != null) vals.push(h11); - if (vals.length === 0) return null; - const avg = vals.reduce((a, b) => a + b, 0) / vals.length; - const v00 = h00 != null ? h00 : avg; - const v10 = h10 != null ? h10 : avg; - const v01 = h01 != null ? h01 : avg; - const v11 = h11 != null ? h11 : avg; - const a = v00 * (1 - tx) + v10 * tx; - const b = v01 * (1 - tx) + v11 * tx; - return a * (1 - tz) + b * tz; - }, - }, - - /** - * Физика — луч (raycast), импульсы, взрывы. - */ - physics: { - /** - * Пустить луч из точки origin в направлении dir. - * Возвращает { hit, ref, point, distance } — hit=true если во что-то - * попали. ref — объект (primitive) в который попали. - * Синхронный — можно звать прямо в onClick для стрельбы. - * const r = game.physics.raycast(game.player.position, game.player.forward); - * if (r.hit) game.scene.delete(r.ref); - * opts: { maxDistance: 100, ignore: [ref, ...] } - */ - raycast(origin, dir, opts) { - opts = opts || {}; - const ox = Number(origin?.x), oy = Number(origin?.y), oz = Number(origin?.z); - let dx = Number(dir?.x), dy = Number(dir?.y), dz = Number(dir?.z); - if (![ox, oy, oz, dx, dy, dz].every(Number.isFinite)) { - return { hit: false, ref: null, point: null, distance: Infinity }; - } - // нормализуем направление - const dlen = Math.sqrt(dx*dx + dy*dy + dz*dz) || 1; - dx /= dlen; dy /= dlen; dz /= dlen; - const maxDist = Number.isFinite(Number(opts.maxDistance)) ? Number(opts.maxDistance) : 100; - const ignore = Array.isArray(opts.ignore) ? opts.ignore : []; - let best = { hit: false, ref: null, point: null, distance: Infinity }; - // перебираем примитивы — ray vs AABB (с учётом поворота вокруг Y) - for (const p of _sceneIndex.primitives) { - if (p.visible === false) continue; - if (ignore.includes(p.ref)) continue; - const hw = (p.sx || 1) / 2, hh = (p.sy || 1) / 2, hd = (p.sz || 1) / 2; - // переводим луч в локальные координаты примитива (обратный поворот по Y) - const ang = -(p.rotationY || 0); - const cos = Math.cos(ang), sin = Math.sin(ang); - const rx = ox - p.x, rz = oz - p.z; - const lox = rx * cos - rz * sin; - const loz = rx * sin + rz * cos; - const loy = oy - p.y; - const ldx = dx * cos - dz * sin; - const ldz = dx * sin + dz * cos; - const ldy = dy; - // slab-тест ray vs AABB - const t = _rayAabb(lox, loy, loz, ldx, ldy, ldz, hw, hh, hd); - if (t != null && t >= 0 && t <= maxDist && t < best.distance) { - best = { - hit: true, ref: p.ref, distance: t, - point: { x: ox + dx*t, y: oy + dy*t, z: oz + dz*t }, - }; - } - } - return best; - }, - /** - * Задать скорость объекту (м/с). Объект полетит с этой скоростью. - * game.physics.setVelocity(ball, { x: 0, y: 10, z: 5 }); - */ - setVelocity(ref, vel) { - if (typeof ref !== 'string' || !vel) return; - _send('physics.setVelocity', { - ref, - vx: Number(vel.x) || 0, vy: Number(vel.y) || 0, vz: Number(vel.z) || 0, - }); - }, - /** - * Толкнуть объект импульсом (резкий толчок). - * game.physics.applyImpulse(box, { x: 15, y: 5, z: 0 }); - */ - applyImpulse(ref, impulse) { - if (typeof ref !== 'string' || !impulse) return; - _send('physics.applyImpulse', { - ref, - ix: Number(impulse.x) || 0, iy: Number(impulse.y) || 0, iz: Number(impulse.z) || 0, - }); - }, - /** - * Взрыв в точке: визуальный эффект + урон игроку и мобам в радиусе. - * game.physics.explode({ x, y, z }, 5, { damage: 40 }); - * opts: { damage: 30, force: 0 } — урон и сила отброса. - */ - explode(pos, radius, opts) { - if (!pos) return; - opts = opts || {}; - _send('physics.explode', { - x: Number(pos.x) || 0, y: Number(pos.y) || 0, z: Number(pos.z) || 0, - radius: Number(radius) || 3, - damage: Number.isFinite(Number(opts.damage)) ? Number(opts.damage) : 30, - force: Number(opts.force) || 0, - }); - }, - /** - * Проходимость объекта или группы (Фаза 5.9, collision groups). - * target — ref объекта ИЛИ тег (тогда применяется ко ВСЕМ объектам - * с этим тегом — теги работают как collision groups). - * on=true — игрок проходит сквозь (объект остаётся видимым), - * on=false — снова твёрдый. - * game.physics.passThrough(wall, true); // одна стена - * game.physics.passThrough('призраки', true); // вся группа по тегу - */ - passThrough(target, on) { - if (typeof target !== 'string' || !target) return; - _send('physics.passThrough', { target, on: !!on }); - }, - }, - - /** - * GUI — управление 2D-интерфейсом (Frame/Text/Button/Image) из скриптов. - */ - gui: { - /** Найти ID элемента по имени. Возвращает строку или null. */ - find(name) { - if (typeof name !== 'string') return null; - const n = name.toLowerCase(); - for (const g of _guiIndex) { - if (g.name && String(g.name).toLowerCase() === n) return g.id; - } - return null; - }, - /** Список ID всех элементов. */ - all() { - return _guiIndex.map(g => g.id); - }, - /** Получить копию свойств элемента. */ - get(id) { - if (typeof id !== 'string') return null; - const found = _guiIndex.find(g => g.id === id); - return found ? { ...found } : null; - }, - /** - * Изменить свойства элемента. - * game.gui.update('gui_xxx', { text: 'Hi', textColor: '#ff0' }); - */ - update(id, patch) { - if (typeof id !== 'string' || !patch || typeof patch !== 'object') return; - _send('gui.update', { id, patch }); - }, - /** Сделать элемент видимым. */ - show(id) { - if (typeof id !== 'string') return; - _send('gui.update', { id, patch: { visible: true } }); - }, - /** Скрыть элемент (но не удалять). */ - hide(id) { - if (typeof id !== 'string') return; - _send('gui.update', { id, patch: { visible: false } }); - }, - /** - * Создать новый элемент. Возвращает локальный ref-id (строку). - * const id = game.gui.create('text', { x: 50, y: 10, text: 'HP: 100' }); - */ - create(type, opts) { - if (typeof type !== 'string') return null; - _localRefSeq++; - const localRef = '_gui_local_' + _localRefSeq; - _send('gui.create', { type, opts: opts || {}, localRef }); - return localRef; - }, - /** Удалить элемент по id. */ - remove(id) { - if (typeof id !== 'string') return; - _send('gui.remove', { id }); - }, - /** - * Подписаться на клик по кнопке (по id). - * game.gui.onClick('gui_xxx', () => { game.log('clicked!'); }); - */ - onClick(id, fn) { - if (typeof id !== 'string' || typeof fn !== 'function') return; - (_guiClickHandlers[id] = _guiClickHandlers[id] || []).push(fn); - }, - /** - * Подписаться на ввод в поле TextBox — срабатывает когда игрок - * нажал Enter. fn получает введённый текст. - * game.gui.onSubmit('gui_name', (text) => { - * game.ui.showText('Привет, ' + text); - * }); - */ - onSubmit(id, fn) { - if (typeof id !== 'string' || typeof fn !== 'function') return; - (_guiSubmitHandlers[id] = _guiSubmitHandlers[id] || []).push(fn); - }, - /** Задача 03: tween свойства GUI-элемента. - * props: { x, y, w, h, rotation, scaleX, scaleY, bgOpacity, textSize, - * bgColor, textColor, borderColor } (любое числовое или hex-цвет). - * opts: { duration, easing, delay, repeat, reverses, onDone } */ - tween(id, props, opts) { - if (typeof id !== 'string' || !id) return null; - if (!props || typeof props !== 'object') return null; - opts = opts || {}; - const tid = ++_tweenSeq; - if (typeof opts.onDone === 'function') _tweenCallbacks[tid] = opts.onDone; - _send('gui.tween', { - tweenId: tid, id, props, - duration: Number.isFinite(Number(opts.duration)) ? Number(opts.duration) : 0.5, - easing: typeof opts.easing === 'string' ? opts.easing : 'ease', - delay: Number.isFinite(Number(opts.delay)) ? Number(opts.delay) : 0, - repeat: Number.isFinite(Number(opts.repeat)) ? Number(opts.repeat) : 0, - reverses: !!opts.reverses, - }); - return tid; - }, - /** Отменить tween по id (возвращённому из game.gui.tween). */ - cancelTween(tweenId) { - if (!Number.isFinite(tweenId)) return; - _send('gui.cancelTween', { tweenId }); - delete _tweenCallbacks[tweenId]; - }, - }, - /** - * Камера — тряска, FOV, привязка к объекту, катсцены (Фаза 5.7). - */ - camera: { - /** - * Тряска камеры. amp в метрах (0.1 = чуть-чуть, 0.5 = сильно), - * dur в секундах. Затухает к 0. - */ - shake(amp, dur) { - const a = Number(amp), d = Number(dur); - if (!Number.isFinite(a) || !Number.isFinite(d) || a <= 0 || d <= 0) return; - _send('camera.shake', { amp: a, dur: d }); - }, - /** - * Угол обзора камеры (FOV) в градусах. 70 — норма, 90 — широкий, - * 40 — «зум». Диапазон 10..130. - * game.camera.setFov(90); - */ - setFov(degrees) { - const d = Number(degrees); - if (Number.isFinite(d)) _send('camera.fov', { degrees: d }); - }, - /** - * Привязать камеру к объекту — она следит за ним. - * ref — объект сцены. opts: { distance, height } — отступ камеры. - * game.camera.focusOn(bossRef, { distance: 12, height: 6 }); - */ - focusOn(ref, opts) { - if (typeof ref !== 'string') return; - opts = opts || {}; - _send('camera.focus', { - ref, - distance: Number.isFinite(Number(opts.distance)) ? Number(opts.distance) : undefined, - height: Number.isFinite(Number(opts.height)) ? Number(opts.height) : undefined, - }); - }, - /** - * Катсцена — плавный пролёт камеры по точкам. - * points — массив позиций камеры [{x,y,z}, ...]. - * opts: { lookAt: [{x,y,z}, ...] — точки взгляда (по одной на - * позицию), segDuration: секунд на отрезок }. - * game.camera.cutscene( - * [{x:0,y:10,z:-20}, {x:0,y:5,z:0}], - * { lookAt: [{x:0,y:0,z:0}, {x:0,y:0,z:0}], segDuration: 3 } - * ); - */ - cutscene(points, opts) { - if (!Array.isArray(points) || points.length < 2) return; - opts = opts || {}; - _send('camera.cutscene', { - points, - lookAt: Array.isArray(opts.lookAt) ? opts.lookAt : [], - segDuration: Number.isFinite(Number(opts.segDuration)) - ? Number(opts.segDuration) : 2, - }); - }, - /** Вернуть камеру под управление игрока. */ - reset() { - _send('camera.reset', {}); - }, - }, - /** - * Управление стандартным HUD движка (HP-бар, hotbar, кнопка меню, чат). - * Нужно для игр которые делают свой UI через game.gui.* и не хотят - * чтобы стандартные элементы мешали. - */ - hud: { - /** Скрыть/показать ВСЕ стандартные HUD-элементы. */ - setVisible(visible) { - _send('hud.setVisible', { visible: !!visible }); - }, - /** Скрыть/показать только хотбар (5 слотов инвентаря снизу). - * Для игр где инвентарь не нужен (магазин/головоломка/симулятор). */ - setHotbarVisible(visible) { - _send('hud.setHotbarVisible', { visible: !!visible }); - }, - /** Скрыть/показать только HP-индикатор (полоска жизней). */ - setHpVisible(visible) { - _send('hud.setHpVisible', { visible: !!visible }); - }, - }, - /** - * Задача 04: модальные сцены (затемнение + GUI поверх + блок ввода). - * - * Типовой кейс: boss-fight intro, открытие лутбокса, диалог с NPC, получил питомца. - * - * const m = game.modal.open({ - * darken: 0.7, // 0..1 — насколько затемнить (по умолчанию 0.5) - * darkenColor: '#000', // цвет затемнения - * target: 'scene', // 'scene' (HUD виден) | 'screen' (всё затемнено) - * blockInput: true, // блокирует WASD/Space/Ctrl (Esc/Tab/Enter работают) - * freezeCamera: true, // камера замирает - * fadeIn: 0.4, // секунды до полного затемнения - * fadeOut: 0.3, - * spotlights: [boss, h1, h2], // 3D-рефы, которые остаются яркими (вырезка mask) - * spotlightRadius: 120, // пиксели — радиус «прожектора» - * pauseSimulation: false, // ставит весь GameRuntime на паузу (мобы замирают) - * muteWorld: false, // приглушает ambient/sfx - * cameraOverride: { // фокус камеры на цель - * target: boss, distance: 8, height: 3, fov: 60, duration: 0.5, - * }, - * content: { elements: [ // временные GUI поверх модала, удалятся при close - * { kind: 'text', x: 50, y: 25, text: 'Франкенслим', textSize: 48, - * textStroke: { color: '#000', width: 3 }, textColor: '#fff' }, - * { kind: 'button', id: 'fight', x: 50, y: 80, w: 20, h: 6, text: 'В бой!' }, - * ]}, - * }); - * game.gui.onClick('fight', () => game.modal.close(m)); - * - * Готовые пресеты: - * game.modal.bossIntro(name, hp, refs) — intro босса с HP-баром - * game.modal.lootbox(items, onPick) — открытие лутбокса - * game.modal.dialog(npcName, lines, onDone) — диалог с NPC построчно - * game.modal.confirmation(title, body, onYes, onNo) — Да/Нет - * - * Только ОДИН модал одновременно. Повторный open мгновенно закрывает предыдущий. - */ - modal: { - _localSeq: 0, - _localToReal: new Map(), // localId → реальный modalId (приходит в modalOpened) - _onCloseFns: [], - open(opts) { - opts = opts || {}; - const localId = ++this._localSeq; - const replyId = '_mopen_' + localId; - _send('modal.open', { opts, replyId }); - // Возвращаем локальный id — реальный придёт асинхронно через modalOpened-event - return localId; - }, - close(modalId) { - // Резолвим локальный id → реальный. Если modalId — локальное число, но - // реальный ещё не пришёл (гонка modalOpened-event), шлём null: модал - // одиночный, null закрывает активный. Передавать локальный id нельзя — - // ModalManager.close сверяет его со своим _state.id и молча игнорит. - let real = null; - if (typeof modalId === 'number') { - real = this._localToReal.has(modalId) ? this._localToReal.get(modalId) : null; - } else if (modalId != null) { - real = modalId; // уже реальный id (строка/число от runtime) - } - _send('modal.close', { modalId: real }); - }, - update(modalId, patch) { - let real = null; - if (typeof modalId === 'number') { - real = this._localToReal.has(modalId) ? this._localToReal.get(modalId) : null; - } else if (modalId != null) { - real = modalId; - } - _send('modal.update', { modalId: real, patch: patch || {} }); - }, - isOpen() { return !!this._isOpenLocal; }, - onClose(fn) { - if (typeof fn === 'function') this._onCloseFns.push(fn); - }, - - // === Пресеты === - /** Intro босса с именем + HP-баром + кнопкой «В бой!» через delay секунд. */ - bossIntro(name, hp, refs, opts) { - opts = opts || {}; - const startBtnDelay = Number.isFinite(opts.startBtnDelay) ? opts.startBtnDelay : 2; - const buttonText = opts.buttonText || 'В бой!'; - const onStart = opts.onStart; - const elements = [ - { kind: 'text', id: '_boss_name', x: 50, y: 22, w: 60, h: 8, anchor: 'center', - text: String(name || 'Босс'), textSize: 48, fontWeight: 900, textColor: '#ffffff', - textStroke: { color: '#000', width: 3 }, bgColor: 'transparent', bgOpacity: 0, - animationPreset: 'glow' }, - { kind: 'text', id: '_boss_hp', x: 50, y: 30, w: 30, h: 6, anchor: 'center', - text: '❤ ' + hp + ' / ' + hp, textSize: 28, fontWeight: 800, textColor: '#22ff66', - textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0 }, - ]; - const m = this.open({ - darken: 0.7, target: 'scene', - blockInput: true, freezeCamera: true, - spotlights: Array.isArray(refs) ? refs : (refs ? [refs] : []), - cameraOverride: refs ? { target: Array.isArray(refs) ? refs[0] : refs, - distance: 8, height: 3, fov: 60, duration: 0.5 } : null, - content: { elements }, - }); - const _modal = this; - const _afterTid = ++_timerSeq; - _timers.push({ id: _afterTid, fn: () => { - _send('gui.create', { type: 'button', opts: { - id: '_boss_start', x: 50, y: 78, w: 20, h: 7, anchor: 'center', - text: buttonText, - bgGradient: { stops: ['#ff5454','#a00000'], angle: 90 }, - borderColor: '#000', borderWidth: 3, borderRadius: 14, - textColor: '#fff', textSize: 22, fontWeight: 900, - textStroke: { color: '#000', width: 2 }, - hover: { scale: 1.08, brightness: 1.2, duration: 0.15 }, - active: { scale: 0.94, duration: 0.08 }, - animationPreset: 'pulse', - }, localRef: '_boss_start' }); - let _started = false; - _guiClickHandlers['_boss_start'] = [() => { - if (_started) return; - _started = true; - delete _guiClickHandlers['_boss_start']; - _modal.close(m); - if (typeof onStart === 'function') { try { onStart(); } catch (e) {} } - }]; - }, delay: startBtnDelay, elapsed: 0, repeat: false }); - return m; - }, - /** Открытие лутбокса. items: [{name, color, icon, rarity}]. onPick(item). */ - lootbox(items, onPick) { - items = Array.isArray(items) ? items.slice(0, 5) : []; - const elements = [ - { kind: 'frame', id: '_lb_bg', x: 50, y: 50, w: 70, h: 50, anchor: 'center', - bgGradient: { stops: ['#1a1a3a','#0a0a1a'], angle: 135 }, - borderColor: '#ffd700', borderWidth: 3, borderRadius: 18 }, - { kind: 'text', id: '_lb_title', x: 50, y: 28, w: 60, h: 8, anchor: 'center', - text: '✨ Лутбокс ✨', textSize: 32, fontWeight: 900, textColor: '#ffd700', - textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0, - animationPreset: 'glow' }, - ]; - for (let i = 0; i < items.length; i++) { - const it = items[i]; - const x = 50 + (i - (items.length - 1) / 2) * 13; - elements.push({ - kind: 'button', id: '_lb_item_' + i, - x: x, y: 50, w: 11, h: 16, anchor: 'center', - text: (it.icon || '*') + '\\n' + (it.name || 'Приз'), - bgColor: it.color || '#3a3a5a', borderRadius: 12, - borderColor: '#ffd700', borderWidth: 2, - textColor: '#fff', textSize: 14, fontWeight: 700, - hover: { scale: 1.1, brightness: 1.3, duration: 0.15 }, - active: { scale: 0.94, duration: 0.08 }, - animationPreset: 'pulse', - }); - } - const m = this.open({ - darken: 0.6, target: 'screen', blockInput: true, - content: { elements }, - }); - const _modal = this; - // _picked: после первого выбора остальные карточки не должны срабатывать, - // пока модал доигрывает fadeOut (иначе onPick зовётся несколько раз). - let _picked = false; - for (let i = 0; i < items.length; i++) { - const id = '_lb_item_' + i; - const it = items[i]; - _guiClickHandlers[id] = [() => { - if (_picked) return; - _picked = true; - for (let j = 0; j < items.length; j++) delete _guiClickHandlers['_lb_item_' + j]; - _modal.close(m); - if (typeof onPick === 'function') { try { onPick(it); } catch (e) {} } - }]; - } - return m; - }, - /** Диалог с NPC построчно. lines: ['Привет', 'Как дела?']. onDone() в конце. */ - dialog(npcName, lines, onDone) { - lines = Array.isArray(lines) ? lines : [String(lines || '')]; - let idx = 0; - const elements = [ - { kind: 'frame', id: '_dlg_bg', x: 50, y: 80, w: 80, h: 25, anchor: 'center', - bgGradient: { stops: ['#1a1a3a','#0a0a1a'], angle: 90 }, - borderColor: '#fff', borderWidth: 2, borderRadius: 12 }, - { kind: 'text', id: '_dlg_name', x: 14, y: 71, w: 22, h: 6, anchor: 'center', - text: String(npcName || 'NPC'), textSize: 20, fontWeight: 900, - textColor: '#ffd700', textStroke: { color: '#000', width: 2 }, - bgColor: 'transparent', bgOpacity: 0 }, - { kind: 'text', id: '_dlg_line', x: 50, y: 80, w: 76, h: 12, anchor: 'center', - text: lines[0], textSize: 22, fontWeight: 600, textColor: '#fff', - textAlign: 'left', bgColor: 'transparent', bgOpacity: 0 }, - { kind: 'button', id: '_dlg_next', x: 88, y: 88, w: 8, h: 6, anchor: 'center', - // На ПОСЛЕДНЕЙ строке кнопка сразу показывает галочку «завершить», - // на остальных — стрелку «дальше». - text: lines.length > 1 ? '▶' : '✓', textSize: 22, fontWeight: 900, - bgColor: '#ffd700', textColor: '#000', borderRadius: 8, - borderColor: '#000', borderWidth: 2, - hover: { scale: 1.1, brightness: 1.2 }, active: { scale: 0.92 }, - animationPreset: 'pulse' }, - ]; - const m = this.open({ - darken: 0.5, target: 'scene', blockInput: true, freezeCamera: true, - content: { elements }, - }); - const _modal = this; - // _done защищает от повторного срабатывания: game.modal.close() доигрывает - // fadeOut асинхронно, кнопка остаётся кликабельной — без guard'а каждый - // лишний клик снова звал onDone (баг «Диалог завершён ×7»). - let _done = false; - _guiClickHandlers['_dlg_next'] = [() => { - if (_done) return; - idx++; - if (idx < lines.length) { - _send('gui.update', { id: '_dlg_line', patch: { text: lines[idx] } }); - // Последняя строка достигнута — превращаем «дальше» в «завершить». - if (idx === lines.length - 1) { - _send('gui.update', { id: '_dlg_next', patch: { text: '✓' } }); - } - } else { - _done = true; - delete _guiClickHandlers['_dlg_next']; // снимаем handler сразу - _modal.close(m); - if (typeof onDone === 'function') { try { onDone(); } catch (e) {} } - } - }]; - return m; - }, - /** Подтверждение Да/Нет. */ - confirmation(title, body, onYes, onNo) { - const elements = [ - { kind: 'frame', id: '_cf_bg', x: 50, y: 50, w: 50, h: 30, anchor: 'center', - bgGradient: { stops: ['#2a2a4a','#0a0a1a'], angle: 135 }, - borderColor: '#fff', borderWidth: 2, borderRadius: 14 }, - { kind: 'text', id: '_cf_title', x: 50, y: 41, w: 44, h: 6, anchor: 'center', - text: String(title || 'Подтверждение'), textSize: 28, fontWeight: 900, - textColor: '#fff', textStroke: { color: '#000', width: 2 }, - bgColor: 'transparent', bgOpacity: 0 }, - { kind: 'text', id: '_cf_body', x: 50, y: 50, w: 44, h: 8, anchor: 'center', - text: String(body || ''), textSize: 16, fontWeight: 500, - textColor: '#cfd0e8', bgColor: 'transparent', bgOpacity: 0 }, - { kind: 'button', id: '_cf_yes', x: 38, y: 60, w: 14, h: 6, anchor: 'center', - text: 'Да', bgGradient: { stops: ['#22ff66','#0a803a'], angle: 90 }, - borderColor: '#000', borderWidth: 2, borderRadius: 10, - textColor: '#fff', textSize: 18, fontWeight: 900, - hover: { scale: 1.08, brightness: 1.2 }, active: { scale: 0.94 } }, - { kind: 'button', id: '_cf_no', x: 62, y: 60, w: 14, h: 6, anchor: 'center', - text: 'Нет', bgGradient: { stops: ['#ff5454','#a00000'], angle: 90 }, - borderColor: '#000', borderWidth: 2, borderRadius: 10, - textColor: '#fff', textSize: 18, fontWeight: 900, - hover: { scale: 1.08, brightness: 1.2 }, active: { scale: 0.94 } }, - ]; - const m = this.open({ - darken: 0.6, target: 'screen', blockInput: true, - content: { elements }, - }); - const _modal = this; - // _answered: одна кнопка снимает обе (Да/Нет) сразу, чтобы пока модал - // доигрывает fadeOut нельзя было нажать вторую и продублировать ответ. - let _answered = false; - const _finish = (cb) => { - if (_answered) return; - _answered = true; - delete _guiClickHandlers['_cf_yes']; - delete _guiClickHandlers['_cf_no']; - _modal.close(m); - if (typeof cb === 'function') { try { cb(); } catch (e) {} } - }; - _guiClickHandlers['_cf_yes'] = [() => _finish(onYes)]; - _guiClickHandlers['_cf_no'] = [() => _finish(onNo)]; - return m; - }, - }, - /** - * Инвентарь игрока (Фаза 4.2) — 5 слотов hot-bar. - * game.inventory.add({ name: 'Зелье', kind: 'item' }) - * game.inventory.has('Зелье') — по имени или modelTypeId - * game.inventory.remove('Зелье') - * game.inventory.list() — массив предметов - * game.inventory.clear() - */ - inventory: { - /** Добавить предмет. item: { name, kind?, modelTypeId?, params? }. */ - add(item) { - if (!item || typeof item !== 'object') return; - _send('inventory.give', { - kind: item.kind || 'item', - modelTypeId: item.modelTypeId || null, - name: item.name || 'Предмет', - params: item.params || {}, - }); - }, - /** Убрать первый предмет по имени или modelTypeId. */ - remove(nameOrModel) { - if (typeof nameOrModel !== 'string') return; - _send('inventory.remove', { name: nameOrModel, modelTypeId: nameOrModel }); - }, - /** True если предмет с таким именем/modelTypeId есть в инвентаре. */ - has(nameOrModel) { - if (typeof nameOrModel !== 'string') return false; - return (_inventory.slots || []).some(s => - s && (s.name === nameOrModel || s.modelTypeId === nameOrModel)); - }, - /** Массив всех предметов инвентаря (без пустых слотов). */ - list() { - return (_inventory.slots || []).filter(Boolean).map(s => ({ ...s })); - }, - /** Активный предмет (выбранный слот hot-bar) или null. */ - active() { - const s = (_inventory.slots || [])[_inventory.activeIndex]; - return s ? { ...s } : null; - }, - /** Очистить весь инвентарь. */ - clear() { - _send('inventory.clear', {}); - }, - }, - /** - * Игроки комнаты (Фаза 4.3 — мультиплеер). - * В одиночной игре (редактор) — только локальный игрок. - * game.players.me() — я { sessionId, name, position, hp, ... } - * game.players.all() — массив всех игроков (включая меня) - * game.players.count() — сколько игроков - */ - players: { - /** Локальный игрок. */ - me() { - return _players.me ? { ..._players.me } : null; - }, - /** Все игроки комнаты (включая меня). */ - all() { - return (_players.list || []).map(p => ({ ...p })); - }, - /** Сколько игроков в комнате. */ - count() { - return (_players.list || []).length; - }, - /** Найти игрока по sessionId или null. */ - get(sessionId) { - const p = (_players.list || []).find(x => x.sessionId === sessionId); - return p ? { ...p } : null; - }, - }, - /** - * Общее состояние комнаты (Фаза 4.3) — данные, видимые всем игрокам. - * В одиночной игре работает как локальное хранилище. - * game.room.set('счёт', 10) - * game.room.get('счёт') → 10 - * game.room.onChange('счёт', (v) => game.ui.showText('Счёт: ' + v)) - */ - room: { - /** Установить значение в общем состоянии комнаты. */ - set(key, value) { - if (typeof key !== 'string' || !key) return; - // Оптимистично обновляем локальное зеркало. - _roomState[key] = value; - _send('room.set', { key, value }); - }, - /** Прочитать значение из общего состояния комнаты. */ - get(key) { - if (typeof key !== 'string') return undefined; - return _roomState[key]; - }, - /** Подписаться на изменение ключа общего состояния. fn(value). */ - onChange(key, fn) { - if (typeof key !== 'string' || typeof fn !== 'function') return; - (_roomChangeHandlers[key] = _roomChangeHandlers[key] || []).push(fn); - }, - }, - /** - * Команды (Фаза 4.4) — для командных игр. - * game.teams.create('Красные', '#ff3333') - * game.teams.create('Синие', '#3366ff') - * game.player.setTeam('Красные') - * game.player.team → 'Красные' - */ - teams: { - /** Создать команду с именем и цветом (#hex). */ - create(name, color) { - if (typeof name !== 'string' || !name) return; - _send('teams.create', { - name, - color: typeof color === 'string' ? color : '#888888', - }); - }, - /** Удалить команду. */ - remove(name) { - if (typeof name !== 'string') return; - _send('teams.remove', { name }); - }, - /** Список всех команд — массив { name, color }. */ - all() { - return _teams.map(t => ({ ...t })); - }, - /** Найти команду по имени или null. */ - get(name) { - const t = _teams.find(x => x.name === name); - return t ? { ...t } : null; - }, - }, - /** - * Связи между объектами (Фаза 5, Constraints). - * weld — склейка: объект B движется вместе с A. - * hinge — петля: вращение вокруг оси (двери, рычаги). - * spring — пружина: упругое колебание (батуты). - * Каждый вызов возвращает объект-связь с методами управления. - */ - constraints: { - /** - * Жёстко склеить объект B с A — B следует за A. - * game.constraints.weld(platformRef, crateRef); - */ - weld(refA, refB) { - if (typeof refA !== 'string' || typeof refB !== 'string') return null; - _constraintRefSeq++; - const localRef = 'constraint:_local_' + _constraintRefSeq; - _send('constraint.create', { kind: 'weld', localRef, refA, refB }); - return { - get ref() { return localRef; }, - remove() { _send('constraint.remove', { ref: localRef }); }, - }; - }, - /** - * Петля: объект вращается вокруг вертикальной оси через pivot. - * opts: { pivotX, pivotZ — точка оси, angle — стартовый угол (°) }. - * Метод setAngle(°) поворачивает объект — для дверей/рычагов. - * const door = game.constraints.hinge(doorRef, { pivotX: 5, pivotZ: 0 }); - * door.setAngle(90); // открыть дверь - */ - hinge(ref, opts) { - if (typeof ref !== 'string') return null; - opts = opts || {}; - _constraintRefSeq++; - const localRef = 'constraint:_local_' + _constraintRefSeq; - _send('constraint.create', { - kind: 'hinge', localRef, ref, - pivotX: Number.isFinite(Number(opts.pivotX)) ? Number(opts.pivotX) : undefined, - pivotZ: Number.isFinite(Number(opts.pivotZ)) ? Number(opts.pivotZ) : undefined, - angle: Number(opts.angle) || 0, - }); - return { - get ref() { return localRef; }, - /** Повернуть к углу (градусы) — объект плавно довернётся. */ - setAngle(deg) { - _send('constraint.hingeAngle', { ref: localRef, deg: Number(deg) || 0 }); - }, - remove() { _send('constraint.remove', { ref: localRef }); }, - }; - }, - /** - * Пружина: объект упруго держится в точке покоя (текущая позиция). - * opts: { stiffness — жёсткость, damping — затухание }. - * Метод push(vx,vy,vz) толкает объект — запускает колебание. - * const trampoline = game.constraints.spring(padRef); - * trampoline.push(0, 12, 0); // подбросить вверх - */ - spring(ref, opts) { - if (typeof ref !== 'string') return null; - opts = opts || {}; - _constraintRefSeq++; - const localRef = 'constraint:_local_' + _constraintRefSeq; - _send('constraint.create', { - kind: 'spring', localRef, ref, - stiffness: Number.isFinite(Number(opts.stiffness)) ? Number(opts.stiffness) : undefined, - damping: Number.isFinite(Number(opts.damping)) ? Number(opts.damping) : undefined, - }); - return { - get ref() { return localRef; }, - /** Толкнуть объект (скорость по осям) — запускает колебание. */ - push(vx, vy, vz) { - _send('constraint.springPush', { - ref: localRef, - vx: Number(vx) || 0, vy: Number(vy) || 0, vz: Number(vz) || 0, - }); - }, - remove() { _send('constraint.remove', { ref: localRef }); }, - }; - }, - }, - /** - * Эффекты-объекты сцены (Фаза 5.2): лучи и следы. - * beam — светящаяся линия между точками (лазеры, мосты, цепи). - * trail — шлейф за движущимся объектом. - */ - fx: { - /** - * Луч между двумя точками. opts: { from, to — {x,y,z} или ref - * объекта (тогда луч следит за ним); color: '#hex', width }. - * game.fx.beam({ from: towerRef, to: {x:0,y:5,z:0}, color: '#ff3344' }); - */ - beam(opts) { - opts = opts || {}; - _fxRefSeq++; - const localRef = 'fx:_local_' + _fxRefSeq; - // Задача 08: расширенные опции (текстура/curved/градиент/billboard). - _send('fx.create', { - kind: 'beam', localRef, - from: _normFxPoint(opts.from), to: _normFxPoint(opts.to), - color: typeof opts.color === 'string' ? opts.color : undefined, - width: Number.isFinite(Number(opts.width)) ? Number(opts.width) : undefined, - texture: opts.texture, customTextureUrl: opts.customTextureUrl, - textureMode: opts.textureMode, textureSpeed: opts.textureSpeed, - textureScale: opts.textureScale, - strokeColor: opts.strokeColor, strokeWidth: opts.strokeWidth, - colorSequence: opts.colorSequence, - transparencySequence: opts.transparencySequence, - widthSequence: opts.widthSequence, - faceMode: opts.faceMode, segments: opts.segments, - curved: opts.curved, curveHeight: opts.curveHeight, - attachOffset: opts.attachOffset, ignoreDepth: opts.ignoreDepth, - }); - return { - get ref() { return localRef; }, - /** Сменить цвет луча. */ - setColor(color) { - _send('fx.beamColor', { ref: localRef, color }); - }, - /** Сменить концы луча ({x,y,z} или ref). */ - setEndpoints(from, to) { - _send('fx.beamEndpoints', { ref: localRef, from, to }); - }, - /** Изменить любые опции луча на лету. */ - update(o) { - _send('fx.beamUpdate', { ref: localRef, opts: o || {} }); - }, - hide() { _send('fx.beamVisible', { ref: localRef, visible: false }); }, - show() { _send('fx.beamVisible', { ref: localRef, visible: true }); }, - remove() { _send('fx.remove', { ref: localRef }); }, - }; - }, - /** - * Стрелка-указатель «иди сюда» (бегущие шевроны + парящий quest-marker - * над целью). Задача 08. - * const arrow = game.fx.pointer({ from: 'player', to: cubeRef, preset: 'guide' }); - * arrow.setTarget(otherRef); arrow.update({ preset: 'quest' }); arrow.remove(); - * preset: 'guide'|'quest'|'danger'|'gift'|'custom'. - * from/to: 'player' | ref-объекта | {x,y,z}. - */ - pointer(opts) { - opts = opts || {}; - _fxRefSeq++; - const localRef = 'fx:_local_' + _fxRefSeq; - _send('fx.createPointer', { - localRef, - from: _normFxPoint(opts.from !== undefined ? opts.from : 'player'), - to: _normFxPoint(opts.to), - preset: opts.preset || 'guide', - color: opts.color, texture: opts.texture, - customTextureUrl: opts.customTextureUrl, - textureSpeed: opts.textureSpeed, width: opts.width, - strokeColor: opts.strokeColor, colorSequence: opts.colorSequence, - curved: opts.curved, curveHeight: opts.curveHeight, - faceMode: opts.faceMode, attachOffset: opts.attachOffset, - }); - return { - get ref() { return localRef; }, - setTarget(to) { _send('fx.pointerTarget', { ref: localRef, to: _normFxPoint(to) }); }, - update(o) { _send('fx.pointerUpdate', { ref: localRef, opts: o || {} }); }, - hide() { _send('fx.beamVisible', { ref: localRef, visible: false }); }, - show() { _send('fx.beamVisible', { ref: localRef, visible: true }); }, - remove() { _send('fx.remove', { ref: localRef }); }, - }; - }, - /** - * Шлейф за объектом. ref — ref-строка объекта. - * opts: { color: '#hex', width, lifetime (сек) }. - * game.fx.trail(ballRef, { color: '#ffcc44', lifetime: 2 }); - */ - trail(ref, opts) { - if (typeof ref !== 'string') return null; - opts = opts || {}; - _fxRefSeq++; - const localRef = 'fx:_local_' + _fxRefSeq; - _send('fx.create', { - kind: 'trail', localRef, ref, - color: typeof opts.color === 'string' ? opts.color : undefined, - width: Number.isFinite(Number(opts.width)) ? Number(opts.width) : undefined, - lifetime: Number.isFinite(Number(opts.lifetime)) ? Number(opts.lifetime) : undefined, - }); - return { - get ref() { return localRef; }, - remove() { _send('fx.remove', { ref: localRef }); }, - }; - }, - }, - /** - * Звуки. Два вида: - * 1. Встроенные пресеты: 'jump', 'pickup', 'win', 'lose', 'click', - * 'hit', 'coin' — game.sound.play('jump'). - * 2. Свои загруженные (Фаза 5.5) — id вида 'sound_N', можно 3D: - * game.sound.play('sound_1', { at: {x,y,z} }) — 3D в точке - * game.sound.play('sound_1', { attach: doorRef }) — 3D у объекта - * game.sound.play('sound_1', { loop: true }) — зациклить - */ - sound: { - /** - * Проиграть звук. id — пресет ('jump'...) или 'sound_N'. - * opts: { volume: 0..1, loop, at: {x,y,z}, attach: ref-строка }. - * Для пользовательского звука возвращает объект с методом stop(). - */ - play(id, opts) { - if (typeof id !== 'string' || !id) return null; - opts = opts || {}; - // Встроенный пресет — старый формат {name}. - if (id.indexOf('sound_') !== 0) { - _send('sound.play', { - name: id, - volume: Number.isFinite(Number(opts.volume)) ? Number(opts.volume) : 1, - pitch: Number.isFinite(Number(opts.pitch)) ? Number(opts.pitch) : 1, - }); - return null; - } - // Пользовательский звук из библиотеки проекта. - _soundRefSeq++; - const localRef = 'sound:_local_' + _soundRefSeq; - _send('sound.play', { - soundId: id, localRef, - volume: Number.isFinite(Number(opts.volume)) ? Number(opts.volume) : 1, - loop: !!opts.loop, - at: (opts.at && Number.isFinite(Number(opts.at.x))) - ? { x: Number(opts.at.x), y: Number(opts.at.y) || 0, z: Number(opts.at.z) || 0 } - : undefined, - attachRef: typeof opts.attach === 'string' ? opts.attach : undefined, - }); - return { - get ref() { return localRef; }, - /** Остановить этот звук. */ - stop() { _send('sound.stop', { ref: localRef }); }, - }; - }, - }, - /** - * Аудио — GD-музыка и SFX. - * game.audio.playSfx('jump') — короткий звук (jump/death/orb_tap/...) - * game.audio.playMusic('epoch_01_main') — фоновая музыка (зацикленная) - * game.audio.stopMusic() - * game.audio.setMuted(true) - */ - audio: { - playSfx(name) { - if (typeof name !== 'string') return; - _send('audio.playSfx', { name }); - }, - playMusic(trackId) { - if (typeof trackId !== 'string') return; - _send('audio.playMusic', { trackId }); - }, - stopMusic() { - _send('audio.stopMusic', {}); - }, - setMuted(muted) { - _send('audio.setMuted', { muted: !!muted }); - }, - }, - /** - * Экономика — алмазы и рейтинг через серверные API. - * Все вызовы асинхронные (с callback), потому что идут через HTTP. - * - * game.economy.reward('level_1_first_pass', function(res) { - * // res = { ok, already_awarded, diamonds, rating, ... } - * }); - * game.economy.dailyCheck(function(res) { ... }); - * game.economy.getBalance(function(res) { - * // res = { diamonds, rating } - * }); - */ - economy: { - reward(achievementId, fn) { - if (typeof achievementId !== 'string') return; - const reqId = 'eco_rwd_' + (++_economyReqSeq); - if (typeof fn === 'function') _economyCallbacks[reqId] = fn; - _send('economy.reward', { reqId, achievementId }); - }, - dailyCheck(fn) { - const reqId = 'eco_daily_' + (++_economyReqSeq); - if (typeof fn === 'function') _economyCallbacks[reqId] = fn; - _send('economy.dailyCheck', { reqId }); - }, - getBalance(fn) { - const reqId = 'eco_bal_' + (++_economyReqSeq); - if (typeof fn === 'function') _economyCallbacks[reqId] = fn; - _send('economy.getBalance', { reqId }); - }, - spend(amount, reason, fn) { - const reqId = 'eco_spend_' + (++_economyReqSeq); - if (typeof fn === 'function') _economyCallbacks[reqId] = fn; - _send('economy.spend', { reqId, amount: Number(amount) || 0, reason: String(reason || '') }); - }, - }, - /** - * Billboard — 3D-таблички с GUI (как BillboardGui в Roblox). - * Создаются через game.scene.spawn('billboard', {x,y,z, template, content}), - * затем настраиваются через game.billboard.set/update. - * - * Пресеты (template): - * - 'shop-item' — иконка + заголовок + sub-строка + кнопка цены - * - 'shop-purchase' — иконка + название + цена (для покупки) - * - 'banner' — крупный текст - * - 'sign' — простой указатель - * - * Пример (4 таблички-апгрейды): - * const refs = ['vis','range','saws','sprink'].map((kind, i) => { - * return game.scene.spawn('billboard', { - * x: -6 + i*4, y: 3, z: 5, - * template: 'shop-item', - * content: { icon: 'hammer', title: 'Апгрейд', sub: '1 > 2', - * price: '$10,000', gradient: ['#ff5a5a','#ff8a3d'] }, - * }); - * }); - * game.billboard.onClick(refs[0], 'buy', () => { - * game.ui.showText('Куплено!'); - * game.billboard.update(refs[0], { sub: '2 > 3', price: '$20,000' }); - * }); - */ - billboard: { - /** - * Полная замена контента таблички. Если пресет тот же — мгновенно - * перерисует. Если template другой — пересоздаст текстуру. - * ref — string-ref из game.scene.spawn() или game.scene.findOne() - * opts — { template?, face?, content?, elements? } - */ - set(ref, opts) { - const refStr = _normRef(ref); - if (!refStr || typeof opts !== 'object' || opts == null) return; - _send('billboard.set', { ref: refStr, ...opts }); - }, - /** - * Частичное обновление таблички. - * Две формы: - * 1) update(ref, patch) - * patch — частичный content: { sub, price, title, icon, gradient } - * Применяется к content пресета (shop-item/banner/sign). - * 2) update(ref, elementId, patch) - * Обновляет конкретный элемент по id (только для template:'card' - * или elements-режима). Например: update(ref, 'buy', { text: '$15,000' }). - * Для пресетов: 'title'|'sub'|'price'|'icon'|'gradient' тоже - * работают как ключи content. - */ - update(ref, secondArg, thirdArg) { - const refStr = _normRef(ref); - if (!refStr) return; - // 3-аргументная форма: update(ref, elementId, patch) - if (typeof secondArg === 'string' && typeof thirdArg === 'object' && thirdArg !== null) { - _send('billboard.update', { - ref: refStr, - elementId: secondArg, - patch: thirdArg, - }); - return; - } - // 2-аргументная форма: update(ref, patch) - if (typeof secondArg === 'object' && secondArg !== null) { - _send('billboard.update', { ref: refStr, patch: secondArg }); - } - }, - /** - * Подписаться на клик по кнопке таблички (shop-item: buttonId='buy'; - * в кастомных elements — id из элемента kind='button'). - * ref — string-ref - * buttonId — id кнопки (по умолчанию 'buy') - * fn — () => void - */ - onClick(ref, buttonId, fn) { - if (typeof fn !== 'function') { - fn = buttonId; - buttonId = 'buy'; - } - // Принудительная нормализация ref в plain-string: Instance-Proxy - // не сериализуется через postMessage (DataCloneError). - const refStr = _normRef(ref); - if (!refStr || typeof fn !== 'function') return; - const bid = String(buttonId || 'buy'); - const key = refStr + ':' + bid; - if (!_billboardClickHandlers[key]) _billboardClickHandlers[key] = []; - _billboardClickHandlers[key].push(fn); - _send('billboard.onClick', { ref: refStr, buttonId: bid }); - }, - }, - /** Окружение: небо, туман, время суток. */ - environment: { - /** Установить цвет неба (clearColor сцены). hex или 'r,g,b'. */ - setSkyColor(color) { - if (typeof color !== 'string') return; - _send('environment.setSkyColor', { color }); - }, - /** Установить туман: {enabled, color, density}. */ - setFog(opts) { - if (typeof opts !== 'object' || !opts) return; - _send('environment.setFog', opts); - }, - /** Установить время суток (часы, 0..24). */ - setTimeOfDay(hours) { - const h = Number(hours); - if (!Number.isFinite(h)) return; - _send('environment.setTimeOfDay', { hours: h }); - }, - }, - /** - * Управление режимами ввода — курсор и камера. - * В режиме 'ui' мышь работает как обычный курсор (как в браузере), - * не вращает камеру. Нужно для меню/инвентарей. - */ - input: { - /** - * Установить cursor-режим: 'ui' = курсор-как-в-браузере, - * 'game' = pointer-lock (мышь крутит камеру). - */ - setCursorMode(mode) { - if (mode !== 'ui' && mode !== 'game') return; - _send('input.setCursorMode', { mode }); - }, - /** - * Подписаться на движение мыши в UI-режиме. - * fn(x, y) — нормализованные координаты [0..1] относительно канваса. - */ - onMouseMove(fn) { - if (typeof fn !== 'function') return; - _mouseMoveHandlers.push(fn); - }, - /** Зажатие ЛКМ в UI-режиме. fn(x, y). */ - onMouseDown(fn) { - if (typeof fn !== 'function') return; - _mouseDownHandlers.push(fn); - }, - /** Отпускание ЛКМ. fn(x, y). */ - onMouseUp(fn) { - if (typeof fn !== 'function') return; - _mouseUpHandlers.push(fn); - }, - }, - /** - * Управление приложением целиком: переходы между страницами и т.д. - */ - app: { - /** Выйти из проекта на страницу ленты Kubikon-игр. */ - exit() { - _send('app.exit', {}); - }, - /** Перейти на произвольный URL (внутри сайта). */ - navigate(url) { - if (typeof url !== 'string' || !url) return; - _send('app.navigate', { url }); - }, - }, - /** - * УНИВЕРСАЛЬНОЕ хранилище сохранений (game saves). - * Любая игра может хранить произвольный JSON-стейт игрока. Под каждую - * игру таблицу создавать не нужно — всё через эти эндпоинты. - * - * namespace — строка типа 'progress', 'stats', 'inventory'. Под каждый - * одна запись на (project, user). Макс 20 namespace. - * data — произвольный объект JSON, до 50KB. - * - * Примеры: - * game.save.set('progress', { level: 3, gold: 250 }); - * game.save.get('progress', fn(data) {...}); - * game.save.merge('progress', { increment: { attempts: 1 } }); - * game.save.leaderboard('progress', 'gold', 'desc', fn(entries) {...}); - */ - save: { - /** Прочитать namespace. fn(data) — data это сохранённый объект или null. */ - get(namespace, fn) { - if (typeof namespace !== 'string' || !namespace) return; - const reqId = 'sg_get_' + (++_saveReqSeq); - if (typeof fn === 'function') _saveCallbacks[reqId] = fn; - _send('save.get', { reqId, namespace }); - }, - /** Прочитать ВСЕ сохранения юзера. fn(allNamespaces) — { ns1: data, ns2: data }. */ - getAll(fn) { - const reqId = 'sg_all_' + (++_saveReqSeq); - if (typeof fn === 'function') _saveCallbacks[reqId] = fn; - _send('save.getAll', { reqId }); - }, - /** Записать (полная замена). data — объект/массив. */ - set(namespace, data) { - if (typeof namespace !== 'string' || !namespace) return; - if (data === undefined || data === null) return; - _send('save.set', { namespace, data }); - }, - /** Слияние с существующим. opts: - * { patch: {...}, increment: { key: delta }, max: { key: value } } - * patch — ключи копируются поверх - * increment — атомарный +=delta (нужно для счётчиков, не теряются - * данные если игрок играет с двух устройств) - * max — новое значение пишется только если оно больше старого */ - merge(namespace, opts) { - if (typeof namespace !== 'string' || !namespace) return; - if (!opts || typeof opts !== 'object') return; - _send('save.merge', { - namespace, - patch: opts.patch || {}, - increment: opts.increment || {}, - max: opts.max || {}, - }); - }, - /** Шорткат: атомарный +1 к счётчику. */ - increment(namespace, key, delta) { - if (typeof namespace !== 'string' || typeof key !== 'string') return; - const d = Number(delta); - const inc = {}; - inc[key] = Number.isFinite(d) ? d : 1; - _send('save.merge', { namespace, patch: {}, increment: inc, max: {} }); - }, - /** Лидерборд по ключу. order='asc' (меньше=лучше) | 'desc' (больше=лучше). - * fn(entries) — массив { rank, user_id, username, value }. */ - leaderboard(namespace, key, order, fn) { - if (typeof namespace !== 'string' || typeof key !== 'string') return; - const reqId = 'sg_lb_' + (++_saveReqSeq); - if (typeof fn === 'function') _saveCallbacks[reqId] = fn; - _send('save.leaderboard', { - reqId, namespace, key, - order: order === 'asc' ? 'asc' : 'desc', - }); - }, - }, - log(...args) { - const parts = args.map(a => { - if (typeof a === 'string') return a; - if (typeof a === 'number' || typeof a === 'boolean') return String(a); - if (a == null) return String(a); - try { return JSON.stringify(a); } catch (e) { return '[object]'; } - }); - _send('log', { level: 'info', text: parts.join(' ') }); - }, - - /** - * Случайное число. - * random() → 0..1 - * random(max) → 0..max - * random(min, max) → min..max - * random(min, max, true) → целое min..max включительно - */ - random(min, max, integer) { - if (min === undefined) return Math.random(); - if (max === undefined) { max = min; min = 0; } - const a = Number(min), b = Number(max); - if (!Number.isFinite(a) || !Number.isFinite(b)) return 0; - if (integer) { - const lo = Math.ceil(Math.min(a, b)); - const hi = Math.floor(Math.max(a, b)); - return Math.floor(Math.random() * (hi - lo + 1)) + lo; - } - return a + Math.random() * (b - a); - }, - - /** - * Расстояние между двумя точками или объектами. - * Аргументы могут быть {x,y,z} или ref-строкой (для ref берём позицию из sceneIndex). - */ - distance(a, b) { - const pa = _resolveToPos(a); - const pb = _resolveToPos(b); - if (!pa || !pb) return Infinity; - const dx = pa.x - pb.x, dy = pa.y - pb.y, dz = pa.z - pb.z; - return Math.sqrt(dx * dx + dy * dy + dz * dz); - }, - - /** - * Отправить именованное сообщение всем скриптам (включая себя). - * Используется для общения между скриптами в разных sandbox'ах. - * game.broadcast('checkpoint', { num: 2 }); - */ - broadcast(name, data) { - if (typeof name !== 'string' || !name) return; - _send('broadcast', { name, data: data == null ? null : data }); - }, - - /** - * Подключить скрипт-модуль и получить его exports. - * Модуль — другой скрипт проекта; в нём пишут в объект exports: - * // скрипт "math_utils": - * exports.add = (a, b) => a + b; - * // обычный скрипт: - * const m = game.require('math_utils'); - * game.log(m.add(2, 3)); // 5 - * Модуль исполняется один раз, дальше отдаётся из кеша. - */ - require(name) { - if (typeof name !== 'string' || !name) return null; - if (_moduleCache[name]) return _moduleCache[name]; - const code = _moduleCode[name]; - if (code == null) { - _send('log', { level: 'error', text: 'game.require: модуль не найден — ' + name }); - return null; - } - try { - const exportsObj = {}; - // модуль видит game и exports; повторный require внутри модуля тоже работает - const moduleFn = new Function('game', 'exports', '"use strict";\\n' + code); - moduleFn(game, exportsObj); - _moduleCache[name] = exportsObj; - return exportsObj; - } catch (err) { - _send('log', { - level: 'error', - text: 'Ошибка в модуле "' + name + '": ' + (err && err.message ? err.message : err), - }); - return null; - } - }, - - /** - * Подписаться на сообщение. - * game.onMessage('checkpoint', (data) => { ... }); - */ - onMessage(name, fn) { - if (typeof name !== 'string' || !name) return; - if (typeof fn !== 'function') return; - (_messageHandlers[name] = _messageHandlers[name] || []).push(fn); - }, - - /** Зажать значение между min и max. */ - clamp(value, min, max) { - const v = Number(value); - const lo = Number(min), hi = Number(max); - if (!Number.isFinite(v)) return 0; - if (v < lo) return lo; - if (v > hi) return hi; - return v; - }, - - /** Линейная интерполяция: lerp(a, b, 0)=a, lerp(a, b, 1)=b. */ - lerp(a, b, t) { - const na = Number(a), nb = Number(b), nt = Number(t); - return na + (nb - na) * nt; - }, -}; - -/** - * Пересечение луча с AABB (в локальных координатах бокса, центр в 0). - * Возвращает расстояние t до точки входа или null если не пересекает. - * Slab-метод. (ox,oy,oz)=начало луча, (dx,dy,dz)=направление (нормализ.), - * (hw,hh,hd)=полуразмеры бокса. - */ -function _rayAabb(ox, oy, oz, dx, dy, dz, hw, hh, hd) { - let tmin = -Infinity, tmax = Infinity; - const axes = [ - [ox, dx, hw], [oy, dy, hh], [oz, dz, hd], - ]; - for (const [o, d, h] of axes) { - if (Math.abs(d) < 1e-9) { - // луч параллелен слою — мимо если начало вне границ - if (o < -h || o > h) return null; - } else { - let t1 = (-h - o) / d; - let t2 = (h - o) / d; - if (t1 > t2) { const tmp = t1; t1 = t2; t2 = tmp; } - if (t1 > tmin) tmin = t1; - if (t2 < tmax) tmax = t2; - if (tmin > tmax) return null; - } - } - // tmin<0 значит начало внутри бокса — берём 0 - return tmin >= 0 ? tmin : (tmax >= 0 ? 0 : null); -} - -/** Резолв позиции из {x,y,z} или ref-строки. */ -function _resolveToPos(arg) { - if (!arg) return null; - if (typeof arg === 'string') { - for (const b of _sceneIndex.blocks) if (b.ref === arg) return { x: b.x, y: b.y, z: b.z }; - for (const m of _sceneIndex.models) if (m.ref === arg) return { x: m.x, y: m.y, z: m.z }; - for (const p of _sceneIndex.primitives) if (p.ref === arg) return { x: p.x, y: p.y, z: p.z }; - return null; - } - if (typeof arg === 'object' && Number.isFinite(arg.x)) { - return { x: Number(arg.x) || 0, y: Number(arg.y) || 0, z: Number(arg.z) || 0 }; - } - return null; -} - -// === Обработчики сообщений из main === -self.onmessage = (e) => { - const { cmd, payload } = e.data || {}; - if (cmd === 'init') { - // payload: { code, target?, selfPosition?, modules? } - if (payload && payload.target) { - _target = payload.target; - if (payload.selfPosition) _selfPosition = payload.selfPosition; - _selfApi = _buildSelfApi(); - } - // modules: { 'имя': 'код' } — все скрипты проекта, доступные через game.require - if (payload && payload.modules && typeof payload.modules === 'object') { - _moduleCode = payload.modules; - } - // Первичный snapshot сцены — заполняем _sceneIndex ДО исполнения кода, - // чтобы findOne()/find() работали в синхронном теле скрипта на старте - // (иначе obj.onTouch(...) не подписывался — объект ещё «не существовал»). - if (payload && payload.initialScene && typeof payload.initialScene === 'object') { - const s = payload.initialScene; - _sceneIndex = { - blocks: s.blocks || [], - models: s.models || [], - primitives: s.primitives || [], - }; - } - try { - // exports передаём всегда — скрипт может быть и модулем (пишет в - // exports), и обычным скриптом (игнорирует его). Без этого - // скрипт-модуль падает с 'exports is not defined' при прямом запуске. - const exportsObj = {}; - const userFn = new Function('game', 'exports', '"use strict";\\n' + payload.code); - userFn(game, exportsObj); - _send('ready', null); - } catch (err) { - _send('log', { level: 'error', text: 'Ошибка скрипта: ' + (err && err.message ? err.message : err) }); - _send('ready', null); - } - } else if (cmd === 'tick') { - const dt = payload && typeof payload.dt === 'number' ? payload.dt : 0; - if (payload && payload.player) { - const pp = payload.player; - if (pp.position) _playerState.position = pp.position; - if (typeof pp.yaw === 'number') _playerState.yaw = pp.yaw; - if (typeof pp.pitch === 'number') _playerState.pitch = pp.pitch; - if (pp.forward) _playerState.forward = pp.forward; - if (typeof pp.crosshair === 'string') _playerState.crosshair = pp.crosshair; - if (typeof pp.hp === 'number') _playerState.hp = pp.hp; - if (typeof pp.maxHp === 'number') _playerState.maxHp = pp.maxHp; - // Кубикон Dash: направление гравитации (+1 / -1). - if (typeof pp.gravityDir === 'number') _playerState.gravityDir = pp.gravityDir; - if (typeof pp.state === 'string') _playerState.state = pp.state; - if (pp.keys && typeof pp.keys === 'object') _playerState.keys = pp.keys; - } - if (payload && payload.selfPosition) { - _selfPosition = payload.selfPosition; - } - if (payload && Array.isArray(payload.mobs)) { - _mobs = payload.mobs; - } - if (payload && Array.isArray(payload.npcs)) { - _npcs = payload.npcs; - } - if (payload && payload.inventory && typeof payload.inventory === 'object') { - _inventory = payload.inventory; - } - if (payload && payload.players && typeof payload.players === 'object') { - _players = payload.players; - } - if (payload && payload.roomState && typeof payload.roomState === 'object') { - _roomState = payload.roomState; - } - if (payload && Array.isArray(payload.teams)) { - _teams = payload.teams; - } - for (const fn of _tickHandlers) { - _safeCall(fn, dt, 'onTick'); - } - // Таймеры game.after / game.every — копим dt, срабатываем при достижении delay. - // Итерируем по копии: callback может вызвать game.after/cancel и изменить _timers. - if (_timers.length > 0 && dt > 0) { - const due = []; - for (const t of _timers) { - t.elapsed += dt; - if (t.elapsed >= t.delay) due.push(t); - } - for (const t of due) { - if (t.repeat) { - // отнимаем delay (не сбрасываем в 0) — равномерный интервал без дрейфа - t.elapsed -= t.delay; - } else { - const i = _timers.indexOf(t); - if (i >= 0) _timers.splice(i, 1); - } - _safeCall(t.fn, undefined, t.repeat ? 'every' : 'after'); - } - } - } else if (cmd === 'state') { - if (payload && payload.player) { - const pp = payload.player; - if (pp.position) _playerState.position = pp.position; - if (typeof pp.yaw === 'number') _playerState.yaw = pp.yaw; - if (typeof pp.pitch === 'number') _playerState.pitch = pp.pitch; - if (pp.forward) _playerState.forward = pp.forward; - if (typeof pp.crosshair === 'string') _playerState.crosshair = pp.crosshair; - if (typeof pp.hp === 'number') _playerState.hp = pp.hp; - if (typeof pp.maxHp === 'number') _playerState.maxHp = pp.maxHp; - // Кубикон Dash: направление гравитации (+1 / -1). - if (typeof pp.gravityDir === 'number') _playerState.gravityDir = pp.gravityDir; - if (typeof pp.state === 'string') _playerState.state = pp.state; - if (pp.keys && typeof pp.keys === 'object') _playerState.keys = pp.keys; - } - if (payload && payload.selfPosition) { - _selfPosition = payload.selfPosition; - } - if (payload && Array.isArray(payload.mobs)) { - _mobs = payload.mobs; - } - if (payload && Array.isArray(payload.npcs)) { - _npcs = payload.npcs; - } - if (payload && payload.inventory && typeof payload.inventory === 'object') { - _inventory = payload.inventory; - } - if (payload && payload.players && typeof payload.players === 'object') { - _players = payload.players; - } - if (payload && payload.roomState && typeof payload.roomState === 'object') { - _roomState = payload.roomState; - } - if (payload && Array.isArray(payload.teams)) { - _teams = payload.teams; - } - } else if (cmd === 'event') { - // payload: { type, ...data } - const t = payload?.type; - if (t === 'click') { - // self.onClick — только если есть target и target совпал - for (const fn of _selfClickHandlers) _safeCall(fn, payload, 'self.onClick'); - } else if (t === 'touch') { - for (const fn of _selfTouchHandlers) _safeCall(fn, payload, 'self.onTouch'); - } else if (t === 'untouch') { - for (const fn of _selfUntouchHandlers) _safeCall(fn, payload, 'self.onUntouch'); - } else if (t === 'interact') { - for (const fn of _selfInteractHandlers) _safeCall(fn, payload, 'self.onInteract'); - } - } else if (cmd === 'globalEvent') { - // payload: { type, ...data } — глобальные события (всем sandbox'ам) - const t = payload?.type; - if (t === 'click') { - for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick'); - } else if (t === 'mouseMove') { - for (const fn of _mouseMoveHandlers) { - try { fn(payload.x, payload.y); } - catch (err) { - _send('log', { level: 'error', text: 'onMouseMove: ' + (err && err.message ? err.message : err) }); - } - } - } else if (t === 'mouseDown') { - for (const fn of _mouseDownHandlers) { - try { fn(payload.x, payload.y); } - catch (err) { - _send('log', { level: 'error', text: 'onMouseDown: ' + (err && err.message ? err.message : err) }); - } - } - } else if (t === 'mouseUp') { - for (const fn of _mouseUpHandlers) { - try { fn(payload.x, payload.y); } - catch (err) { - _send('log', { level: 'error', text: 'onMouseUp: ' + (err && err.message ? err.message : err) }); - } - } - } else if (t === 'playerTouch') { - for (const fn of _globalTouchHandlers) _safeCall(fn, payload, 'onPlayerTouch'); - } else if (t === 'instTouch' || t === 'instUntouch' || t === 'instClick') { - // Касание/клик произвольного объекта (findOne(x).onTouch/onUntouch/onClick). - const b = _instTouchHandlers.get(payload && payload.ref); - if (b) { - const list = t === 'instTouch' ? b.touch : t === 'instUntouch' ? b.untouch : b.click; - for (const fn of list) _safeCall(fn, payload, 'inst.' + t); - } - } else if (t === 'hpChange') { - for (const fn of _hpChangeHandlers) _safeCall(fn, payload, 'onHpChange'); - } else if (t === 'mobKilled') { - for (const fn of _mobKilledHandlers) _safeCall(fn, payload, 'onMobKilled'); - } else if (t === 'npcDeath') { - // payload: { npcId, position } - const npcId = payload.npcId; - const ev = { id: npcId, position: payload.position }; - // Глобальные подписчики game.onNpcDeath(fn). - for (const fn of _globalNpcDeathHandlers) _safeCall(fn, ev, 'onNpcDeath'); - // Адресные подписки npc.onDeath — по числовому id ИЛИ по - // локальному ref, который при спавне привязали к этому id. - const keys = [String(npcId)]; - for (const [lref, real] of Object.entries(_npcLocalToReal)) { - if (real === npcId) keys.push(lref); - } - for (const key of keys) { - const arr = _npcDeathHandlers[key] || []; - for (const fn of arr) _safeCall(fn, ev, 'npc.onDeath'); - } - } else if (t === 'toolUse') { - // payload: { tool: {kind, modelTypeId, name}, point, target } - const ev = { - tool: payload.tool || null, - point: payload.point || null, - target: payload.target || null, - }; - for (const fn of _toolUseHandlers) _safeCall(fn, ev, 'onToolUse'); - } else if (t === 'cutsceneDone') { - // Катсцена камеры завершилась (Фаза 5.7). - for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone'); - } else if (t === 'playerJoin') { - // payload: { sessionId, name } - for (const fn of _playerJoinHandlers) _safeCall(fn, payload, 'onPlayerJoin'); - } else if (t === 'playerLeave') { - for (const fn of _playerLeaveHandlers) _safeCall(fn, payload, 'onPlayerLeave'); - } else if (t === 'roomChange') { - // payload: { key, value } — изменилось общее состояние комнаты. - const arr = _roomChangeHandlers[payload.key] || []; - for (const fn of arr) _safeCall(fn, payload.value, 'room.onChange:' + payload.key); - } else if (t === 'mpMessage') { - // payload: { from, name, data } — адресное сообщение. - const arr = _mpMessageHandlers[payload.name] || []; - for (const fn of arr) { - _safeCall(fn, { from: payload.from, data: payload.data }, - 'onMessage:' + payload.name); - } - } else if (t === 'playerDied') { - for (const fn of _playerDiedHandlers) _safeCall(fn, undefined, 'onPlayerDied'); - } else if (t === 'playerJump') { - for (const fn of _playerJumpHandlers) _safeCall(fn, undefined, 'onPlayerJump'); - } else if (t === 'playerLand') { - for (const fn of _playerLandHandlers) _safeCall(fn, undefined, 'onPlayerLand'); - } else if (t === 'keydown') { - const key = String(payload.key || '').toLowerCase(); - const arr = _globalKeyDownHandlers[key] || []; - for (const fn of arr) _safeCall(fn, payload, 'onKey:' + key); - const wild = _globalKeyDownHandlers['*'] || []; - for (const fn of wild) _safeCall(fn, payload, 'onKey(*)'); - } else if (t === 'keyup') { - const key = String(payload.key || '').toLowerCase(); - const arr = _globalKeyUpHandlers[key] || []; - for (const fn of arr) _safeCall(fn, payload, 'onKeyUp:' + key); - const wild = _globalKeyUpHandlers['*'] || []; - for (const fn of wild) _safeCall(fn, payload, 'onKeyUp(*)'); - } else if (t === 'message') { - const name = String(payload.name || ''); - const arr = _messageHandlers[name] || []; - for (const fn of arr) _safeCall(fn, payload.data, 'onMessage:' + name); - } else if (t === 'guiClick') { - const id = String(payload.id || ''); - const localId = payload.localId != null ? String(payload.localId) : null; - // Собираем handlers по id, по локальному ref и по имени элемента — - // скрипт мог подписаться любым из этих ключей. - // _matched защищает от двойного вызова если несколько ключей ведут - // к одному и тому же массиву handlers. - const _matched = new Set(); - for (const key of _guiHandlerKeys(id, localId)) { - const arr = _guiClickHandlers[key]; - if (!arr || _matched.has(arr)) continue; - _matched.add(arr); - for (const fn of arr) _safeCall(fn, { id }, 'gui.onClick:' + key); - } - } else if (t === 'guiSubmit') { - const id = String(payload.id || ''); - const localId = payload.localId != null ? String(payload.localId) : null; - const val = payload.value != null ? String(payload.value) : ''; - const _matched = new Set(); - for (const key of _guiHandlerKeys(id, localId)) { - const arr = _guiSubmitHandlers[key]; - if (!arr || _matched.has(arr)) continue; - _matched.add(arr); - for (const fn of arr) _safeCall(fn, val, 'gui.onSubmit:' + key); - } - } else if (t === 'billboardClick') { - // payload: { ref, button } — клик по кнопке 3D-таблички. - // Ищем handlers и по реальному ref (primitive:NN), и по локальному - // ref если такой есть (на случай если скрипт подписался по - // локальному ref от scene.spawn). - const realRef = String(payload.ref || ''); - const button = String(payload.button || 'buy'); - const tryKeys = [realRef + ':' + button]; - // Если есть локальный ref, ведущий к этому real — тоже попробуем - // (скрипт мог подписаться на ref сразу после game.scene.spawn, - // когда ref был ещё локальным _local_N). - for (const [local, real] of Object.entries(_spawnLocalToReal || {})) { - if (real === realRef) tryKeys.push(local + ':' + button); - } - for (const key of tryKeys) { - const arr = _billboardClickHandlers[key] || []; - for (const fn of arr) _safeCall(fn, { ref: realRef, button }, - 'billboard.onClick:' + key); - } - } else if (t === 'modalOpened') { - // Задача 04: реальный modalId от runtime. worker сразу вернул скрипту - // локальный id (чтобы он мог его сохранить и звать close/update); здесь - // запоминаем маппинг local→real, иначе close(m) уходит с локальным id - // и ModalManager.close его не узнаёт (баг «закрывается только по Esc»). - try { - const mm = (typeof game !== 'undefined') && game.modal; - if (mm && payload && payload.replyId) { - const localId = Number(String(payload.replyId).replace(/^_mopen_/, '')); - if (Number.isFinite(localId) && payload.modalId != null) { - mm._localToReal.set(localId, payload.modalId); - mm._isOpenLocal = true; - } - } - } catch (e) {} - } else if (t === 'modalClosed') { - // Модал закрыт (fadeOut доиграл) — снимаем флаг и зовём onClose-подписчиков. - try { - const mm = (typeof game !== 'undefined') && game.modal; - if (mm) { - mm._isOpenLocal = false; - const cbs = mm._onCloseFns || []; - for (const fn of cbs) _safeCall(fn, payload && payload.id, 'modal.onClose'); - } - } catch (e) {} - } else if (t === 'skinChanged') { - // Задача 07: скин игрока сменился — обновляем зеркало и зовём подписчиков. - const slug = payload && payload.slug; - if (slug) { - _currentSkin = slug; - for (const fn of _skinChangeHandlers) _safeCall(fn, slug, 'player.onSkinChange'); - } - } else if (t === 'skinUnlocked') { - const slug = payload && payload.slug; - if (slug && _unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); - } - } else if (cmd === 'sceneSnapshot') { - // payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? } - if (payload) { - _sceneIndex = { - blocks: payload.blocks || [], - models: payload.models || [], - primitives: payload.primitives || [], - }; - // детект дельт и эмит events для Instance (если кто-то подписан). - try { _detectSnapshotDeltas(); } catch (e) {} - } - } else if (cmd === 'guiSnapshot') { - // payload: массив всех GUI-элементов (для game.gui.find/get/all) - _guiIndex = Array.isArray(payload) ? payload : []; - } else if (cmd === 'skinsSnapshot') { - // Задача 07: payload { all:[{slug,name,kind,category,price}], unlocked:[slug], current } - if (payload && typeof payload === 'object') { - _skinsIndex = Array.isArray(payload.all) ? payload.all : []; - _unlockedSkins = Array.isArray(payload.unlocked) ? payload.unlocked.slice() : []; - _currentSkin = payload.current || _currentSkin; - if (Number.isFinite(payload.coins)) _skinCoins = payload.coins; - } - } else if (cmd === 'dataSnapshot') { - // payload: { ref: { key: value } } — атрибуты всех объектов - _dataIndex = payload && typeof payload === 'object' ? payload : {}; - } else if (cmd === 'terrainHeightmap') { - // payload: { origin:{x,z}, step, cols, rows, heights:[] } - // Карта высот гладкого ландшафта для game.scene.surfaceY. - _terrainHM = payload || null; - } else if (cmd === 'saveResponse') { - // payload: { reqId, result } - const reqId = payload && payload.reqId; - const cb = reqId && _saveCallbacks[reqId]; - if (cb) { - delete _saveCallbacks[reqId]; - try { cb(payload.result); } catch (e) {} - } - } else if (cmd === 'economyResponse') { - // payload: { reqId, result } - const reqId = payload && payload.reqId; - const cb = reqId && _economyCallbacks[reqId]; - if (cb) { - delete _economyCallbacks[reqId]; - try { cb(payload.result); } catch (e) {} - } - } else if (cmd === 'tweenDone') { - // payload: { tweenId } — твин доиграл, зовём onDone - const tid = payload && payload.tweenId; - const cb = tid != null && _tweenCallbacks[tid]; - if (cb) { - delete _tweenCallbacks[tid]; - _safeCall(cb, undefined, 'tween.onDone'); - } - } else if (cmd === 'npcSpawned') { - // payload: { localRef, npcId } — async-спавн NPC завершён. - // Запоминаем маппинг, чтобы npc.onDeath по локальному ref работал. - if (payload && payload.localRef != null) { - _npcLocalToReal[payload.localRef] = payload.npcId; - } - } else if (cmd === 'spawnResolved') { - // payload: { localRef, realRef } — scene.spawn создал объект. - // Запоминаем маппинг для getPosition и т.п. - if (payload && payload.localRef && payload.realRef) { - _spawnLocalToReal[payload.localRef] = payload.realRef; - } - } else if (cmd === 'stop') { - _tickHandlers = []; - _timers = []; - _selfClickHandlers = []; - _selfTouchHandlers = []; - _selfUntouchHandlers = []; - _selfInteractHandlers = []; - _instTouchHandlers.clear(); - _globalKeyDownHandlers = {}; - _globalKeyUpHandlers = {}; - _globalClickHandlers = []; - _globalTouchHandlers = []; - _mouseMoveHandlers = []; - _mouseDownHandlers = []; - _mouseUpHandlers = []; - _mobKilledHandlers = []; - _hpChangeHandlers = []; - _playerDiedHandlers = []; - _playerJumpHandlers = []; - _playerLandHandlers = []; - _messageHandlers = {}; - _guiClickHandlers = {}; - _guiSubmitHandlers = {}; - _npcDeathHandlers = {}; - _globalNpcDeathHandlers = []; - _npcLocalToReal = {}; - _spawnLocalToReal = {}; - _npcs = []; - _toolUseHandlers = []; - _inventory = { slots: [], activeIndex: 0 }; - _players = { me: null, list: [] }; - _roomState = {}; - _playerJoinHandlers = []; - _playerLeaveHandlers = []; - _cutsceneDoneHandlers = []; - _mpMessageHandlers = {}; - _roomChangeHandlers = {}; - _teams = []; - _constraintRefSeq = 0; - _fxRefSeq = 0; - _soundRefSeq = 0; - } -}; - -_send('boot', null); -`; - -/** - * Создаёт URL Worker-кода для new Worker(url). - */ -export function getWorkerSourceUrl() { - const blob = new Blob([SOURCE], { type: 'application/javascript' }); - return URL.createObjectURL(blob); -} +/** + * ScriptSandboxWorker.js — код, исполняющийся внутри Web Worker. + * + * НЕ импортируется напрямую через ES-import. Загружается через blob-URL, + * созданный в ScriptSandbox.js. + * + * Архитектура: пользовательский скрипт получает ТОЛЬКО объект `game`. + * Любая операция (двигать игрока, лог) превращается в команду + * postMessage в main thread. Main thread исполняет на Babylon-сцене и + * присылает обратно state-update'ы. + * + * API (этап 2.3.1): + * game.player.position — {x, y, z}, обновляется main thread'ом + * game.player.teleport(x, y, z) + * game.onTick(fn) — fn(dt) каждый кадр + * game.log(...args) — лог в Console + * + * game.self — {kind, ref, position} — объект-носитель + * (только для скриптов с target) + * game.self.onClick(fn) — fn(event) при клике по объекту в Play + * game.self.onTouch(fn) — fn(event) когда игрок касается объекта + * game.self.move(x, y, z) — переместить объект (для моделей/примитивов) + * game.self.delete() — удалить объект-носитель + */ + +const SOURCE = ` +"use strict"; + +// === Внутреннее состояние Worker'а === +let _tickHandlers = []; +let _playerState = { + position: { x: 0, y: 0, z: 0 }, + yaw: 0, + pitch: 0, + forward: { x: 0, y: 0, z: 1 }, // нормализованный вектор взгляда + crosshair: 'none', // 'none' | 'dot' | 'cross' | 'circle' + hp: 100, + maxHp: 100, + state: 'ground', // 'ground' | 'air' | 'water' + keys: {}, // { 'w': true, 'space': true } — зажатые сейчас клавиши +}; +// target скрипта (если есть) — пришёл при init +let _target = null; +// Зеркало position объекта-носителя (если target.kind != null) +let _selfPosition = { x: 0, y: 0, z: 0 }; +// Снимок живых мобов — обновляется каждый tick из main thread +let _mobs = []; +// Снимок NPC (Фаза 4.1) — обновляется каждый tick из main thread. +// Каждый: { id, name, x, y, z, hp, maxHp, mode }. +let _npcs = []; +// Счётчик локальных ref'ов для NPC, заспавненных скриптом. +let _npcRefSeq = 0; +// Маппинг локальный ref ('npc:_local_N') → реальный числовой npcId. +// Заполняется когда main thread присылает 'npcSpawned' после async-спавна. +let _npcLocalToReal = {}; +// Маппинг локальный ref scene.spawn ('primitive:_local_N') → реальный +// ('primitive:N'). main thread шлёт 'spawnResolved' после создания. +// Нужно чтобы getPosition и др. находили заспавненный объект в _sceneIndex. +let _spawnLocalToReal = {}; +// Подписки npc.onDeath: ключ = локальный ref ИЛИ строка-id → [fn]. +let _npcDeathHandlers = {}; +// Глобальные подписки game.onNpcDeath(fn). +let _globalNpcDeathHandlers = []; +// Снимок инвентаря (Фаза 4.2): { slots: [...], activeIndex }. +let _inventory = { slots: [], activeIndex: 0 }; +// Подписки game.player.onToolUse(fn). +let _toolUseHandlers = []; +// Подписки placement-режима (задача 11): game.placement.onPlace/onCancel/onMove. +let _placeOnPlaceHandlers = []; +let _placeOnCancelHandlers = []; +let _placeOnMoveHandlers = []; +let _invUiSlotClickHandlers = []; +// Мультиплеер (Фаза 4.3): снимок игроков комнаты { me, list }. +let _players = { me: null, list: [] }; +// Общее состояние комнаты game.room.get/set — зеркало из main thread. +let _roomState = {}; +// Подписки game.onPlayerJoin / onPlayerLeave / game.onMessage(name). +let _playerJoinHandlers = []; +let _playerLeaveHandlers = []; +// Подписки game.onCutsceneDone(fn) — катсцена камеры доиграла (Фаза 5.7). +let _cutsceneDoneHandlers = []; +let _mpMessageHandlers = {}; // name → [fn] +// Подписки game.room.onChange(key, fn): key → [fn]. +let _roomChangeHandlers = {}; +// Команды (Фаза 4.4): массив { name, color } — зеркало из main thread. +let _teams = []; +// Счётчик локальных ref'ов для связей-constraints (Фаза 5). +let _constraintRefSeq = 0; +// Счётчик локальных ref'ов для лучей/следов (Фаза 5.2). +let _fxRefSeq = 0; +// Счётчик локальных ref'ов для звуков (Фаза 5.5). +let _soundRefSeq = 0; +// Подписки на события объекта (self.*) +let _selfClickHandlers = []; +let _selfTouchHandlers = []; +let _selfUntouchHandlers = []; +// Подписки self.onInteract — взаимодействие по клавише E (ProximityPrompt) +let _selfInteractHandlers = []; +// Подписки на касание/клик ПРОИЗВОЛЬНОГО объекта (findOne(x).onTouch). +// ref → { touch:[fn], untouch:[fn], click:[fn] }. Движок следит за AABB этих +// объектов (cmd 'inst.watchTouch') и шлёт обратно instTouch/instUntouch/instClick. +const _instTouchHandlers = new Map(); +function _instHandlerBucket(ref) { + let b = _instTouchHandlers.get(ref); + if (!b) { b = { touch: [], untouch: [], click: [] }; _instTouchHandlers.set(ref, b); } + return b; +} +// Зеркало всех GUI-элементов (синхронизируется main через cmd='guiSnapshot') +let _guiIndex = []; +// Задача 07: зеркало скинов (синхронизируется через 'skinsSnapshot'). +// _skinsIndex — все встроенные скины [{slug,name,kind,category,price}]. +// _unlockedSkins — slug'и доступные игроку. _currentSkin — активный. +let _skinsIndex = []; +let _unlockedSkins = []; +let _currentSkin = null; +let _skinChangeHandlers = []; +let _skinCoins = 0; // локальная валюта магазина скинов (рублики проекта) +// Подписки game.gui.onClick(id, fn) +let _guiClickHandlers = {}; +// Подписки game.gui.onSubmit(id, fn) — ввод в TextBox завершён (Enter) +let _guiSubmitHandlers = {}; +// Подписки game.billboard.onClick(ref, buttonId, fn) — клик по кнопке +// 3D-таблички. Ключ имеет вид ref + ":" + buttonId где ref — строка +// из game.scene.spawn() или game.scene.findOne() в формате +// 'primitive:NN' либо локальный '_local_X'. Резолв в main (GameRuntime), +// сюда приходит событие 'billboardClick' с готовым ref='primitive:NN'. +let _billboardClickHandlers = {}; +// Для GUI-события с реальным id вернуть набор ключей, под которыми +// Нормализовать точку для fx.beam/fx.pointer перед postMessage. +// game.scene.findOne() возвращает Instance-PROXY — его НЕЛЬЗЯ передать через +// postMessage (structured clone бросает DataCloneError → весь скрипт молча +// падает в воркере, стрелка/луч не создаётся). Конвертируем proxy/объект-с-ref +// в ref-строку ('primitive:NN'); 'player' и {x,y,z} пропускаем как есть. +function _normFxPoint(p) { + if (p == null) return p; + if (typeof p === 'string') return p; // 'player' | 'primitive:NN' + if (typeof p === 'object') { + if (typeof p.ref === 'string') return p.ref; // Instance-proxy + if (Number.isFinite(p.x) && Number.isFinite(p.y) && Number.isFinite(p.z)) { + return { x: p.x, y: p.y, z: p.z }; // чистая точка + } + try { const s = String(p); if (s && s !== '[object Object]') return s; } catch (e) {} + } + return p; +} + +// могли быть зарегистрированы handlers: сам id + имя элемента (скрипт +// часто подписывается через game.gui.onClick('ИмяКнопки', fn)). +function _guiHandlerKeys(id, localId) { + const keys = [id]; + // localId — ref который вернул gui.create() ('_gui_local_N'); скрипт мог + // подписаться по нему, если не задавал явный id. + if (localId != null && localId !== id) keys.push(localId); + // name элемента — скрипт часто подписывается game.gui.onClick('ИмяКнопки', fn). + const el = _guiIndex.find(g => g.id === id); + if (el && el.name && el.name !== id && !keys.includes(el.name)) keys.push(el.name); + return keys; +} + +// Найти запись NPC в снапшоте по локальному ref. Снапшот приходит с +// числовыми id, локальный ref → id через _npcLocalToReal. +function _findNpcState(localRef) { + const id = _npcLocalToReal[localRef]; + if (id == null) return null; + return _npcs.find(n => n.id === id) || null; +} + +// Фабрика прокси-объекта NPC. Методы шлют команды с локальным ref — +// main thread резолвит его в реальный npcId. +function _makeNpcProxy(ref) { + return { + get ref() { return ref; }, + /** Актуальная позиция NPC {x,y,z} или null (пока не заспавнен). */ + get position() { + const st = _findNpcState(ref); + return st ? { x: st.x, y: st.y, z: st.z } : null; + }, + /** Текущее HP или null. */ + get hp() { + const st = _findNpcState(ref); + return st ? st.hp : null; + }, + /** Имя NPC или null. */ + get name() { + const st = _findNpcState(ref); + return st ? st.name : null; + }, + /** Идти в точку (XZ). */ + moveTo(x, z) { + _send('npc.moveTo', { ref, x: Number(x) || 0, z: Number(z) || 0 }); + }, + /** Задать скорость NPC (м/с) — на лету. Напр. медленный подход + * в кат-сцене → быстрая погоня в игре. */ + setSpeed(speed) { + const s = Number(speed); + if (Number.isFinite(s) && s > 0) { + _send('npc.setSpeed', { ref, speed: s }); + } + }, + /** Следовать за объектом: 'player' или ref объекта сцены. */ + follow(target) { + _send('npc.follow', { ref, target }); + }, + /** Остановиться. */ + stop() { + _send('npc.stop', { ref }); + }, + /** Реплика над головой на duration секунд (по умолчанию 3). */ + say(text, duration) { + _send('npc.say', { + ref, + text: String(text == null ? '' : text), + duration: Number.isFinite(Number(duration)) ? Number(duration) : 3, + }); + }, + /** Нанести урон NPC. */ + damage(amount) { + _send('npc.damage', { ref, amount: Number(amount) || 0 }); + }, + /** Убрать NPC со сцены. */ + remove() { + _send('npc.remove', { ref }); + }, + /** Колбэк при гибели этого NPC. fn получает {id, position}. */ + onDeath(fn) { + if (typeof fn === 'function') { + (_npcDeathHandlers[ref] = _npcDeathHandlers[ref] || []).push(fn); + } + }, + }; +} +// Глобальные подписки +let _globalKeyDownHandlers = {}; // { 'w': [fn, fn], ... } — ключи нормализованы в lower-case +let _globalKeyUpHandlers = {}; +let _globalClickHandlers = []; +let _globalTouchHandlers = []; +// Колбэки на движение мыши в UI-режиме (game.input.onMouseMove) +let _mouseMoveHandlers = []; +let _mouseDownHandlers = []; +let _mouseUpHandlers = []; +// Колбэк на убийство моба (зомби и т.п.) — fn({mobType, position}) +let _mobKilledHandlers = []; + +// SaveGame API: счётчик request-id и map колбэков по reqId +let _saveReqSeq = 0; +const _saveCallbacks = {}; +// Economy API (GD-награды): request-id → callback +let _economyReqSeq = 0; +const _economyCallbacks = {}; +// Колбэк на изменение HP игрока (для логирования урона/смерти из скриптов) +let _hpChangeHandlers = []; +// Подписки на события игрока: смерть / прыжок / приземление +let _playerDiedHandlers = []; +let _playerJumpHandlers = []; +let _playerLandHandlers = []; +// Broadcast между sandbox'ами: имя сообщения → массив обработчиков +let _messageHandlers = {}; +// Счётчик для локальных ref'ов спавненных через game.scene.spawn +let _localRefSeq = 0; + +// Твины (game.tween): id → callback onDone. Сами твины крутит main-thread +// (GameRuntime), сюда возвращается только событие завершения по reqId. +let _tweenSeq = 0; +const _tweenCallbacks = {}; + +// Таймеры (game.after / game.every). Каждый: { id, fn, delay, elapsed, repeat } +// repeat=false → after (один раз), repeat=true → every (циклично). +// Тикаются в обработчике cmd='tick' по накоплению dt. +let _timers = []; +let _timerSeq = 0; +// Зеркало всех объектов сцены (заполняется main через cmd='sceneSnapshot' каждый раз +// когда что-то меняется). Это позволяет game.scene.find/all/get работать синхронно. +// { blocks: [{ref, type, x, y, z, name?}], models: [...], primitives: [...] } +let _sceneIndex = { blocks: [], models: [], primitives: [] }; + +// Карта высот гладкого ландшафта — для game.scene.surfaceY(x,z). +// Приходит один раз через cmd='terrainHeightmap'. Формат: +// { origin:{x,z}, step, cols, rows, heights:number[] } +let _terrainHM = null; + +// Атрибуты объектов (game.scene.setData/getData). Зеркало из main thread. +// Формат: { ref: { key: value, ... } }. Синхронизируется через cmd='dataSnapshot'. +// getData читает отсюда синхронно, setData шлёт команду в main. +let _dataIndex = {}; + +// ═══════════════════════════════════════════════════════════════════════ +// Instance-proxy (паритет со студией): game.scene.find/findOne/all возвращают +// Proxy с методами (onTouch/onUntouch/onClick, tween, move, changed.connect, +// position/name/parent/children и т.д.). Coerces в строку-ref через +// Symbol.toPrimitive/valueOf/toString, поэтому старый код (scene.setColor и +// т.п., принимавший строковый ref) продолжает работать без изменений. +const _instCache = new Map(); // ref → Instance proxy +const _instEvents = new Map(); // ref → { propChanged: [{prop,fn}], destroying: [fn] } +const _instLastValues = new Map(); // ref → { x, y, z, name } предыдущий snapshot + +function _safeCall2(fn, args, where) { + try { fn.apply(null, args); } + catch (err) { + _send('log', { + level: 'error', + text: 'Ошибка в ' + where + ': ' + (err && err.message ? err.message : err), + }); + } +} + +function _getOrCreateInstance(ref, kindHint) { + if (!ref || typeof ref !== 'string') return null; + if (_instCache.has(ref)) return _instCache.get(ref); + + const target = { + get ref() { return ref; }, + get kind() { + if (kindHint) return kindHint; + for (const b of _sceneIndex.blocks) if (b.ref === ref) return 'block'; + for (const m of _sceneIndex.models) if (m.ref === ref) return 'model'; + for (const p of _sceneIndex.primitives) if (p.ref === ref) return 'primitive'; + return 'unknown'; + }, + toString() { return ref; }, + }; + + const proxy = new Proxy(target, { + get(t, prop) { + if (prop === 'ref' || prop === 'kind') return t[prop]; + if (prop === 'toString') return t.toString; + if (prop === Symbol.toPrimitive) return () => ref; + if (prop === 'valueOf') return () => ref; + + // === Геттеры из snapshot === + if (prop === 'position') { + for (const arr of [_sceneIndex.blocks, _sceneIndex.models, _sceneIndex.primitives]) { + for (const o of arr) if (o.ref === ref) return { x: o.x, y: o.y, z: o.z }; + } + return null; + } + if (prop === 'name') { + for (const arr of [_sceneIndex.models, _sceneIndex.primitives]) { + for (const o of arr) if (o.ref === ref) return o.name || null; + } + return null; + } + if (prop === 'parent') { + const data = _dataIndex[ref]; + const parentRef = data && data.__parent; + return parentRef ? _getOrCreateInstance(parentRef) : null; + } + if (prop === 'children') { + const data = _dataIndex[ref]; + const ids = (data && data.__children) || []; + return ids.map(id => _getOrCreateInstance(id)).filter(Boolean); + } + if (prop === 'descendants') { + return () => { + const out = []; + const stack = [...((_dataIndex[ref] || {}).__children || [])]; + while (stack.length) { + const id = stack.pop(); + const inst = _getOrCreateInstance(id); + if (inst) { + out.push(inst); + const kids = (_dataIndex[id] || {}).__children || []; + for (const k of kids) stack.push(k); + } + } + return out; + }; + } + + // === События === + if (prop === 'changed') { + return { + connect(propName, fn) { + if (typeof fn !== 'function') return; + let evs = _instEvents.get(ref); + if (!evs) { evs = { propChanged: [], destroying: [] }; _instEvents.set(ref, evs); } + evs.propChanged.push({ prop: propName, fn }); + }, + }; + } + if (prop === 'destroying') { + return { + connect(fn) { + if (typeof fn !== 'function') return; + let evs = _instEvents.get(ref); + if (!evs) { evs = { propChanged: [], destroying: [] }; _instEvents.set(ref, evs); } + evs.destroying.push(fn); + }, + }; + } + + // === Методы === + if (prop === 'destroy' || prop === 'delete') return () => _send('scene.delete', { ref }); + if (prop === 'clone') return (offset) => _send('scene.clone', { ref, offset: offset || {} }); + if (prop === 'setAttribute') return (k, v) => _send('scene.setData', { ref, key: k, value: v }); + if (prop === 'getAttribute') return (k) => { + const data = _dataIndex[ref]; + return data ? data[k] : undefined; + }; + if (prop === 'hasTag') return (t) => { + const data = _dataIndex[ref]; + return Array.isArray(data && data.__tags) && data.__tags.includes(t); + }; + if (prop === 'addTag') return (t) => _send('scene.tag', { ref, tag: t }); + if (prop === 'removeTag') return (t) => _send('scene.untag', { ref, tag: t }); + if (prop === 'tween') return (props, opts) => game.tween(ref, props, opts); + if (prop === 'move') return (x, y, z) => _send('scene.move', { ref, x, y, z }); + if (prop === 'setLabel') return (text, o) => _send('scene.setLabel', { ref, text, opts: o || {} }); + if (prop === 'clearLabel') return () => _send('scene.clearLabel', { ref }); + + // === События касания/клика ПРОИЗВОЛЬНОГО объекта === + // findOne('coin').onTouch(fn) — fn() когда игрок коснулся объекта. + // Аналог Roblox part.Touched:Connect. Движок начинает следить за AABB + // объекта (inst.watchTouch) и шлёт instTouch на rising edge. + if (prop === 'onTouch') return (fn) => { + if (typeof fn !== 'function') return; + _instHandlerBucket(ref).touch.push(fn); + _send('inst.watchTouch', { ref }); + }; + if (prop === 'onUntouch') return (fn) => { + if (typeof fn !== 'function') return; + _instHandlerBucket(ref).untouch.push(fn); + _send('inst.watchTouch', { ref }); + }; + if (prop === 'onClick') return (fn) => { + if (typeof fn !== 'function') return; + _instHandlerBucket(ref).click.push(fn); + _send('inst.watchClick', { ref }); + }; + + return undefined; + }, + set(t, prop, value) { + if (prop === 'name') { + _send('inst.set', { ref, prop: 'name', value: String(value) }); + return true; + } + if (prop === 'parent') { + const parentRef = value && typeof value === 'object' + ? (value.ref || value.toString()) + : (value || null); + _send('inst.setParent', { ref, parentRef }); + return true; + } + if (prop === 'position') { + if (value && typeof value === 'object') { + const x = Number(value.x), y = Number(value.y), z = Number(value.z); + if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) { + _send('scene.move', { ref, x, y, z }); + } + } + return true; + } + if (prop === 'color') { + _send('scene.setColor', { ref, color: String(value) }); + return true; + } + if (prop === 'transparency' || prop === 'opacity') { + const v = Number(value); + if (Number.isFinite(v)) { + const op = prop === 'transparency' ? (1 - v) : v; + _send('scene.setOpacity', { ref, value: op }); + } + return true; + } + if (prop === 'visible') { + _send('scene.setVisible', { ref, visible: !!value }); + return true; + } + if (prop === 'canCollide') { + _send('scene.setCollide', { ref, collide: !!value }); + return true; + } + if (prop === 'material') { + _send('scene.setMaterial', { ref, name: String(value) }); + return true; + } + return true; + }, + }); + + _instCache.set(ref, proxy); + return proxy; +} + +/** Триггер событий изменения свойства (вызывается при дельте snapshot'а). */ +function _emitInstChange(ref, prop, newVal, oldVal) { + const evs = _instEvents.get(ref); + if (!evs) return; + for (const rec of evs.propChanged) { + if (rec.prop === prop) { + _safeCall2(rec.fn, [newVal, oldVal], 'inst.changed:' + prop); + } + } +} + +/** Триггер destroying — объект больше не в snapshot. */ +function _emitInstDestroying(ref) { + const evs = _instEvents.get(ref); + if (evs) { + for (const fn of evs.destroying) { + _safeCall2(fn, [], 'inst.destroying'); + } + } + _instEvents.delete(ref); + _instCache.delete(ref); + _instLastValues.delete(ref); +} + +/** + * Сравнение нового snapshot со старым — детект дельт для событий. + * Вызывается из обработчика 'sceneSnapshot' ПОСЛЕ обновления _sceneIndex. + */ +function _detectSnapshotDeltas() { + const live = new Set(); + for (const arr of [_sceneIndex.blocks, _sceneIndex.models, _sceneIndex.primitives]) { + for (const o of arr) { + live.add(o.ref); + if (!_instCache.has(o.ref)) continue; + const prev = _instLastValues.get(o.ref) || {}; + if (prev.x !== undefined && (prev.x !== o.x || prev.y !== o.y || prev.z !== o.z)) { + _emitInstChange(o.ref, 'position', + { x: o.x, y: o.y, z: o.z }, + { x: prev.x, y: prev.y, z: prev.z }); + } + if (prev.name !== undefined && prev.name !== o.name) { + _emitInstChange(o.ref, 'name', o.name, prev.name); + } + _instLastValues.set(o.ref, { x: o.x, y: o.y, z: o.z, name: o.name }); + } + } + for (const ref of [..._instCache.keys()]) { + if (live.has(ref)) continue; + const inst = _instCache.get(ref); + if (inst && inst.kind === 'primitive') { + _emitInstDestroying(ref); + } + } +} + +// Модули (game.require). Код всех скриптов-модулей приходит при init. +// _moduleCode — { 'имя': 'код модуля' } +// _moduleCache — { 'имя': exports } — кеш исполненных модулей +let _moduleCode = {}; +let _moduleCache = {}; + +// Утилиты безопасной отправки в main +const _send = (cmd, payload) => { + try { postMessage({ cmd, payload }); } catch (e) {} +}; + +// Нормализация ref: строка → она сама; Instance-прокси → поле .ref; +// иначе null. Нужно чтобы billboard.set/update/onClick принимали и +// строковый ref ('primitive:NN'), и объект, у которого есть .ref. +function _normRef(ref) { + if (typeof ref === 'string') return ref || null; + if (ref && typeof ref === 'object') { + if (typeof ref.ref === 'string' && ref.ref) return ref.ref; + const s = String(ref); + return s && s !== '[object Object]' ? s : null; + } + return null; +} + +const _safeCall = (fn, arg, where) => { + try { fn(arg); } + catch (err) { + _send('log', { + level: 'error', + text: 'Ошибка в ' + where + ': ' + (err && err.message ? err.message : err), + }); + } +}; + +// Внутренний хелпер: запланировать удаление объекта через seconds секунд. +// Используется для lifetime в scene.spawn и для scene.deleteAfter. +const _scheduleDelete = (ref, seconds) => { + const s = Number(seconds); + if (typeof ref !== 'string' || !ref || !Number.isFinite(s) || s < 0) return; + _timers.push({ + id: ++_timerSeq, + fn: () => _send('scene.delete', { ref }), + delay: s, elapsed: 0, repeat: false, + }); +}; + +// === Публичное API game.* === + +// Объект self создаётся ПОСЛЕ получения init с target. +// Если target нет (глобальный скрипт), game.self === null. +let _selfApi = null; + +function _buildSelfApi() { + if (!_target) return null; + const isGui = _target.kind === 'gui'; + const api = { + get kind() { return _target.kind; }, + // ref — строка формата 'primitive:N' / 'model:N' / 'block:x,y,z', + // единая со scene.find/all и пригодная для scene.* / physics.* / tween. + get ref() { + const k = _target.kind; + if (k === 'primitive' || k === 'model' || k === 'userModel') { + const id = _target.id ?? _target.ref; + return id != null ? k + ':' + id : null; + } + if (k === 'block') { + const r = _target.ref || _target; + if (r && r.x != null) return 'block:' + r.x + ',' + r.y + ',' + r.z; + return null; + } + return _target.ref ?? _target.id ?? null; + }, + get position() { return { ..._selfPosition }; }, + /** + * Свойства GUI-элемента (только для скриптов с target.kind='gui'): + * game.self.props — текущие свойства (имя, текст, x/y/w/h, цвет...) + */ + get props() { + if (!isGui) return null; + const id = _target.id ?? _target.ref; + const found = _guiIndex.find(g => g.id === id); + return found ? { ...found } : null; + }, + onClick(fn) { + if (typeof fn === 'function') _selfClickHandlers.push(fn); + }, + onTouch(fn) { + if (typeof fn === 'function') _selfTouchHandlers.push(fn); + }, + /** Игрок ВЫШЕЛ из объекта (был внутри AABB и вышел). Полезно для триггер-зон. */ + onUntouch(fn) { + if (typeof fn === 'function') _selfUntouchHandlers.push(fn); + }, + /** + * Взаимодействие по клавише E. Когда игрок подходит близко к объекту — + * над объектом появляется подсказка «[E] ...», по нажатию E срабатывает fn. + * game.self.onInteract(() => { + * game.ui.showText('Дверь открыта!'); + * }, { text: 'Открыть дверь', distance: 4 }); + * opts: { text: 'Взаимодействовать', distance: 4 (метры), key: 'e' }. + */ + onInteract(fn, opts) { + if (typeof fn !== 'function') return; + _selfInteractHandlers.push(fn); + // регистрируем объект как интерактивный — main покажет подсказку + _send('self.registerInteract', { + target: _target, + text: (opts && typeof opts.text === 'string') ? opts.text : 'Взаимодействовать', + distance: (opts && Number.isFinite(opts.distance)) ? opts.distance : 4, + key: (opts && typeof opts.key === 'string') ? opts.key.toLowerCase() : 'e', + }); + }, + move(x, y, z) { + const nx = Number(x), ny = Number(y), nz = Number(z); + if (Number.isFinite(nx) && Number.isFinite(ny) && Number.isFinite(nz)) { + _send('self.move', { target: _target, x: nx, y: ny, z: nz }); + } + }, + delete() { + _send('self.delete', { target: _target }); + }, + /** + * Изменить свойства GUI-элемента (только для target.kind='gui'). + * game.self.update({ text: 'Новый текст', textColor: '#ff0000' }); + */ + update(patch) { + if (!isGui || !patch || typeof patch !== 'object') return; + const id = _target.id ?? _target.ref; + _send('gui.update', { id, patch }); + }, + /** Шорткат для смены текста (для Text/Button). */ + setText(text) { + if (!isGui) return; + const id = _target.id ?? _target.ref; + _send('gui.update', { id, patch: { text: String(text == null ? '' : text) } }); + }, + /** Сделать элемент видимым. */ + show() { + if (!isGui) return; + const id = _target.id ?? _target.ref; + _send('gui.update', { id, patch: { visible: true } }); + }, + /** Скрыть элемент. */ + hide() { + if (!isGui) return; + const id = _target.id ?? _target.ref; + _send('gui.update', { id, patch: { visible: false } }); + }, + }; + return api; +} + +const game = { + player: { + /** Позиция «низа ног» игрока. */ + get position() { return { ..._playerState.position }; }, + /** + * Угол поворота игрока вокруг Y (в радианах). + * 0 = смотрит в +Z, π/2 = +X, π = -Z, -π/2 = -X. + */ + get yaw() { return _playerState.yaw || 0; }, + /** Наклон вверх/вниз (в радианах). >0 = смотрит вверх. */ + get pitch() { return _playerState.pitch || 0; }, + /** + * Нормализованный вектор взгляда (направление куда смотрит игрок). + * Удобно использовать для спавна объектов «перед собой»: + * const f = game.player.forward; + * game.scene.spawn('block:grass', { x: p.x + f.x*3, y: p.y, z: p.z + f.z*3 }); + */ + get forward() { return { ..._playerState.forward }; }, + /** + * Команда локального игрока (Фаза 4.4) — имя команды или null. + * Назначается через game.player.setTeam('Красные'). + */ + get team() { + return (_players.me && _players.me.team) || null; + }, + /** + * Прицел в Play: 'none' | 'dot' | 'cross' | 'circle'. + * Чтение возвращает текущее значение, запись — меняет в рантайме: + * game.player.crosshair = 'cross'; + */ + get crosshair() { return _playerState.crosshair || 'none'; }, + set crosshair(v) { + const allowed = ['none', 'dot', 'cross', 'circle']; + const s = String(v || 'none').toLowerCase(); + if (!allowed.includes(s)) return; + _playerState.crosshair = s; + _send('player.crosshair', { type: s }); + }, + teleport(x, y, z) { + const nx = Number(x), ny = Number(y), nz = Number(z); + if (Number.isFinite(nx) && Number.isFinite(ny) && Number.isFinite(nz)) { + _send('player.teleport', { x: nx, y: ny, z: nz }); + } + }, + /** Сдвинуть игрока ТОЛЬКО по X (не трогая Z и Y). Для раннеров — + * смена полосы без отмены продвижения autorun. */ + setLaneX(x) { + const nx = Number(x); + if (Number.isFinite(nx)) _send('player.setLaneX', { x: nx }); + }, + /** Развернуть модель игрока на угол yaw (радианы). Для кат-сцен, + * где игрок стоит лицом в нужную сторону. yaw=0 — лицом в +Z. */ + setFacing(yaw) { + const y = Number(yaw); + if (Number.isFinite(y)) _send('player.setFacing', { yaw: y }); + }, + /** Проиграть эмоцию персонажа: 'wave'|'dance'|'cheer'|'sit'|'paint'. + * Работает только для R15-скинов. Разовая анимация поверх движения. */ + playEmote(name) { + if (typeof name === 'string') _send('player.emote', { name }); + }, + /** Прервать текущую эмоцию персонажа. */ + stopEmote() { + _send('player.stopEmote', {}); + }, + /** Текущее HP игрока (зеркало из main thread). */ + get hp() { return _playerState.hp ?? 100; }, + get maxHp() { return _playerState.maxHp ?? 100; }, + /** Текущее направление гравитации в Кубикон Dash (+1 вниз, -1 вверх). */ + get gravityDir() { return _playerState.gravityDir ?? 1; }, + /** Жив ли игрок (hp > 0). */ + get alive() { return (_playerState.hp ?? 100) > 0; }, + /** + * Состояние игрока: 'ground' (на земле), 'air' (в воздухе/прыжке), + * 'water' (в воде). + * if (game.player.state === 'air') { ... } + */ + get state() { return _playerState.state || 'ground'; }, + /** + * Зажата ли клавиша ПРЯМО СЕЙЧАС (для плавного движения по удержанию). + * key — 'w','a','s','d','space','shift','arrowup'... (lowercase). + * game.onTick(() => { if (game.player.isKeyDown('w')) { ... } }); + */ + isKeyDown(key) { + if (typeof key !== 'string') return false; + return !!_playerState.keys[key.toLowerCase()]; + }, + /** + * Нанести урон игроку. Учитываются i-frames (повторный вызов + * в течение ~0.5с проигнорится). + */ + damage(amount) { + const a = Number(amount); + if (!Number.isFinite(a) || a <= 0) return; + _send('player.damage', { amount: a }); + }, + /** Мгновенно убить игрока (игнорит i-frames). */ + kill() { + _send('player.damage', { amount: 99999 }); + }, + /** Восстановить здоровье. */ + heal(amount) { + const a = Number(amount); + if (!Number.isFinite(a) || a <= 0) return; + _send('player.heal', { amount: a }); + }, + /** + * Вернуть игрока на spawn-point с полным HP. + * Если spawnPoint в проекте задан — телепортирует туда. + */ + respawn() { + _send('player.respawn', null); + }, + /** + * Множитель скорости передвижения. 1 = норма, 1.5 = +50%, 0.5 = вдвое медленнее. + */ + setSpeed(mul) { + const m = Number(mul); + if (Number.isFinite(m) && m > 0) _send('player.setSpeed', { mul: m }); + }, + /** + * Множитель силы прыжка. 1 = норма, 1.5 = выше, 2 = ещё выше. + */ + setJumpPower(mul) { + const m = Number(mul); + if (Number.isFinite(m) && m > 0) _send('player.setJumpPower', { mul: m }); + }, + /** + * Подфаза 3.11 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md. + * Надеть на игрока аксессуар (шляпа/инструмент/причёска/лицо) из + * каталога Рублокса. itemId — числовой id из rublox_items + * (только published — драфты дизайнеров не видны). + * Пример: game.player.equipAccessory(42); // надеть шляпу id=42 + */ + equipAccessory(itemId) { + const id = Number(itemId); + if (Number.isFinite(id) && id > 0) { + _send('player.equipAccessory', { itemId: id }); + } + }, + /** + * Снять аксессуар из слота: 'hat'|'tool'|'tool_left'|'hair'|'face'. + * Пример: game.player.unequipSlot('hat'); + */ + unequipSlot(slot) { + const s = String(slot || '').trim(); + if (s) _send('player.unequipSlot', { slot: s }); + }, + /** Снять все аксессуары. */ + unequipAll() { + _send('player.unequipAll', {}); + }, + /** + * Множитель гравитации. 1 = норма (-22 м/с²), 1.23 = GD-стиль (-27 м/с²). + * Работает в обоих направлениях gravityDir. + */ + setGravityMul(mul) { + const m = Number(mul); + if (Number.isFinite(m) && m > 0) _send('player.setGravityMul', { mul: m }); + }, + /** + * GD-гейммод Ship: тап-удержание = подъём (вертолёт-стиль). + * Обычный jump-импульс отключается, корабль управляется только Space. + */ + setShipMode(enabled) { + _send('player.setShipMode', { enabled: !!enabled }); + }, + /** + * GD-гейммод UFO: каждый отдельный тап Space = микропрыжок в воздухе + * (даже без касания земли). Обычный прыжок отключается. + */ + setUfoMode(enabled) { + _send('player.setUfoMode', { enabled: !!enabled }); + }, + /** + * GD-гейммод Wave: движение жёстко под ±45°. + * Space зажат → vy = +autoRunSpeed; отпущен → vy = -autoRunSpeed. + * Гравитация и прыжок отключены. + */ + setWaveMode(enabled) { + _send('player.setWaveMode', { enabled: !!enabled }); + }, + /** + * GD-гейммод Robot: высота прыжка зависит от длительности удержания Space. + * Тап = низкий прыжок (~1.5м), удержание 0.35с = высокий (~4м). Прыжок только с земли. + */ + setRobotMode(enabled) { + _send('player.setRobotMode', { enabled: !!enabled }); + }, + /** + * Двойной прыжок (true/false) — второй прыжок в воздухе. + */ + setDoubleJump(enabled) { + _send('player.setDoubleJump', { enabled: !!enabled }); + }, + /** + * Проиграть эмоцию-анимацию персонажа один раз. + * name: 'wave' (помахать) | 'dance' (танец) | 'cheer' (радость) | 'sit' (сесть). + * game.onKey('e', () => game.player.playAnimation('wave')); + */ + playAnimation(name) { + if (typeof name !== 'string') return; + _send('player.playAnimation', { name }); + }, + /** Прервать текущую эмоцию персонажа. */ + stopAnimation() { + _send('player.stopAnimation', {}); + }, + /** + * Скользкость поверхности под игроком. 0 = нормальное движение + * (мгновенная остановка), 0.85 = «лёд» (скользит после отпускания + * клавиш). 1 = полностью скользко (инерция бесконечная). + */ + setIceFriction(value) { + const v = Number(value); + if (Number.isFinite(v)) { + _send('player.setIceFriction', { value: v }); + } + }, + /** + * Кубикон Dash: авто-бег по +X со скоростью speed (м/с). + * Передай 0 чтобы отключить. Работает только в sideview-камере — + * иначе скрипт сразу после autoRun() должен звать setCameraMode('sideview'). + * Пример: game.player.setAutoRun(8); game.player.setCameraMode('sideview'); + */ + setAutoRun(speed) { + const s = Number(speed); + if (Number.isFinite(s)) _send('player.setAutoRun', { speed: s }); + }, + /** + * Мгновенный подброс игрока вверх. strength=1 = обычный прыжок, + * strength=2 = в 2 раза выше. Не требует Space — срабатывает сразу. + * Используется для трамплинов в Кубикон Dash. + */ + boostJump(strength) { + const s = Number(strength); + if (Number.isFinite(s) && s > 0) _send('player.boostJump', { strength: s }); + }, + /** + * Кубикон Dash: перевернуть гравитацию (как blue orb / gravity portal в GD). + * После flipGravity игрок прыгает к потолку. Повторный вызов возвращает. + * Доступно только в sideview-режиме. + */ + flipGravity() { + _send('player.flipGravity', {}); + }, + /** + * Задать вертикальную скорость игрока (м/с). +значение = вверх, - = вниз. + * Используется для трамплинов (vy=16), jump orb (vy=14), boost-зон и т.д. + * Не зависит от _shipMode/_waveMode/_robotMode — просто перезаписывает _vy. + */ + setVy(vy) { + const v = Number(vy); + if (Number.isFinite(v)) _send('player.setVy', { vy: v }); + }, + /** + * Явно установить направление гравитации: 1 = вниз (норма), -1 = вверх. + */ + setGravityDir(dir) { + const d = Number(dir); + if (d === 1 || d === -1) _send('player.setGravityDir', { dir: d }); + }, + /** + * Показать/скрыть основной скин игрока. Используется в Кубикон Dash: + * скрываем человечка, рисуем куб-примитив через скрипт. + */ + setSkinVisible(visible) { + _send('player.setSkinVisible', { visible: !!visible }); + }, + /** + * === Задача 07: скины игрока (любая 3D-модель + магазин) === + * Сменить активный скин в Play (без перезагрузки сцены). + * game.player.setSkin('squirrel-donut'); // встроенный + * game.player.setSkin('character-a'); // человек + * Возвращает «локальный Promise» (объект с .then) — реальная смена + * асинхронна (грузится .glb). Для большинства игр можно не ждать. + */ + setSkin(slug) { + if (typeof slug !== 'string' || !slug) return; + _currentSkin = slug; + if (_unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); + _send('player.setSkin', { slug }); + }, + /** Дать игроку скин (разблокировать — например после покупки). */ + unlockSkin(slug) { + if (typeof slug !== 'string' || !slug) return; + if (_unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); + _send('player.unlockSkin', { slug }); + }, + /** Список slug'ов скинов, доступных игроку (разблокированных). */ + getAvailableSkins() { + return _unlockedSkins.slice(); + }, + /** Все встроенные скины с метаданными: [{slug,name,kind,category,price}]. */ + getAllSkins() { + return _skinsIndex.map(s => ({ ...s })); + }, + /** Текущий активный скин (slug). */ + getCurrentSkin() { + return _currentSkin; + }, + /** Подписка на смену скина: fn(slug). */ + onSkinChange(fn) { + if (typeof fn === 'function') _skinChangeHandlers.push(fn); + }, + /** Открыть встроенный GUI-магазин скинов (если включён в проекте). */ + openSkinShop() { + _send('player.openSkinShop', {}); + }, + /** Закрыть магазин скинов. */ + closeSkinShop() { + _send('player.closeSkinShop', {}); + }, + /** Игровая валюта магазина скинов (рублики проекта, ЛОКАЛЬНЫЕ — + * не путать с серверной экономикой game.economy). */ + getSkinCoins() { + return _skinCoins; + }, + /** Задать баланс валюты магазина (например стартовые 200). */ + setSkinCoins(amount) { + const n = Number(amount); + if (!Number.isFinite(n)) return; + _skinCoins = Math.max(0, Math.floor(n)); + _send('player.setSkinCoins', { amount: _skinCoins }); + }, + /** Добавить валюту магазина (награда за что-то). */ + addSkinCoins(amount) { + const n = Number(amount); + if (!Number.isFinite(n)) return; + _skinCoins = Math.max(0, _skinCoins + Math.floor(n)); + _send('player.setSkinCoins', { amount: _skinCoins }); + }, + /** + * Режим камеры: 'first' | 'third' | 'front' | 'sideview'. + * 'sideview' нужен для Кубикон Dash — камера фиксируется сбоку, + * yaw/pitch от мыши/тача игнорируются. + */ + setCameraMode(mode) { + if (typeof mode !== 'string') return; + _send('player.setCameraMode', { mode }); + }, + /** Задача 02: установить дистанцию камеры (для third-person). */ + setCameraZoom(distance) { + const d = Number(distance); + if (!Number.isFinite(d)) return; + _send('player.setCameraZoom', { distance: d }); + }, + /** Задача 02: границы зума колесом мыши. По умолчанию 0.5..32. */ + setCameraZoomLimits(min, max) { + const mn = Number(min), mx = Number(max); + if (!Number.isFinite(mn) || !Number.isFinite(mx)) return; + _send('player.setCameraZoomLimits', { min: mn, max: mx }); + }, + /** Задача 02: Shift-Lock — курсор в центре, корпус лицом к камере. */ + setShiftLock(on) { + _send('player.setShiftLock', { on: !!on }); + }, + /** + * Присед: уменьшает высоту хитбокса игрока с 1.8 до 0.9 ед. + * Используется чтобы пройти под низким потолком. + */ + setCrouch(enabled) { + _send('player.setCrouch', { enabled: !!enabled }); + }, + /** + * Назначить активную точку возрождения. При respawn / смерти + * игрок появится здесь. Аргумент: + * - ref объекта ('primitive:N' / 'model:N' / 'block:x,y,z') — + * игрок встанет НАД этим объектом; + * - объект {x, y, z} — точные координаты. + * game.self.onInteract(() => game.player.setSpawn(game.self.ref)); + */ + setSpawn(target) { + if (typeof target === 'string' && target) { + _send('player.setSpawn', { ref: target }); + } else if (target && typeof target === 'object' + && Number.isFinite(Number(target.x))) { + _send('player.setSpawn', { + x: Number(target.x), + y: Number(target.y), + z: Number(target.z), + }); + } + }, + /** + * Дать игроку инструмент/оружие в инвентарь (Фаза 4.2). + * toolType — id модели ('weapon-sword', 'blaster-blaster-a', ...) + * или произвольное имя предмета. + * opts: { name, equip:true (сразу взять в руки), params }. + * Оружие (blaster-* / weapon-*) получает kind='weapon' + параметры + * боя; прочее — kind='tool'. + * game.player.giveTool('blaster-blaster-a', { equip: true }); + */ + giveTool(toolType, opts) { + if (typeof toolType !== 'string' || !toolType) return; + opts = opts || {}; + const isBlaster = toolType.indexOf('blaster') === 0; + const isMelee = toolType.indexOf('weapon-') === 0; + let kind = 'tool'; + let params = {}; + if (isBlaster) { + kind = 'weapon'; + params = { + damage: 25, fireRate: 0.2, range: 60, + magazine: 12, reserve: 48, + }; + } else if (isMelee) { + kind = 'weapon'; + params = { weaponKind: 'melee', damage: 35, fireRate: 0.6, range: 3 }; + } + // opts.params переопределяет дефолты. + if (opts.params && typeof opts.params === 'object') { + params = { ...params, ...opts.params }; + } + _send('inventory.give', { + kind, + modelTypeId: toolType, + name: typeof opts.name === 'string' ? opts.name : toolType, + params, + equip: opts.equip === true, + }); + }, + /** Убрать инструмент/оружие из инвентаря по id модели или имени. */ + removeTool(toolType) { + if (typeof toolType !== 'string') return; + _send('inventory.remove', { modelTypeId: toolType, name: toolType }); + }, + /** + * Подписка: игрок применил инструмент (ЛКМ с предметом в активном + * слоте). fn получает { tool: {kind, modelTypeId, name}, point, target }. + */ + onToolUse(fn) { + if (typeof fn === 'function') _toolUseHandlers.push(fn); + }, + /** + * Назначить игроку команду (Фаза 4.4). Команда должна быть + * заранее создана через game.teams.create(). null/'' убирает. + * game.teams.create('Красные', '#ff3333'); + * game.player.setTeam('Красные'); + */ + setTeam(name) { + _send('player.setTeam', { team: typeof name === 'string' ? name : null }); + }, + }, + /** + * Таймер прохождения для лидерборда. + * game.timer.start() — запустить отсчёт (с нуля, отображается в HUD). + * game.timer.stop() — остановить (но не отправлять). + * game.timer.submit() — остановить + отправить рекорд в лидерборд. + * Сервер сохраняет если время лучше предыдущего. + */ + timer: { + start() { _send('timer.start', null); }, + stop() { _send('timer.stop', null); }, + submit() { _send('timer.submit', null); }, + }, + get self() { return _selfApi; }, + onTick(fn) { + if (typeof fn === 'function') _tickHandlers.push(fn); + }, + /** + * Выполнить fn ОДИН раз через seconds секунд. + * Возвращает id таймера — его можно отменить через game.cancel(id). + * game.after(3, () => game.ui.showText('Прошло 3 секунды!')); + */ + after(seconds, fn) { + const s = Number(seconds); + if (!Number.isFinite(s) || s < 0 || typeof fn !== 'function') return null; + const id = ++_timerSeq; + _timers.push({ id, fn, delay: s, elapsed: 0, repeat: false }); + return id; + }, + /** + * Выполнять fn КАЖДЫЕ seconds секунд (циклично). + * Возвращает id таймера — остановить через game.cancel(id). + * const t = game.every(1, () => game.log('тик')); + * game.after(10, () => game.cancel(t)); // через 10с остановить + */ + every(seconds, fn) { + const s = Number(seconds); + if (!Number.isFinite(s) || s <= 0 || typeof fn !== 'function') return null; + const id = ++_timerSeq; + _timers.push({ id, fn, delay: s, elapsed: 0, repeat: true }); + return id; + }, + /** Отменить таймер (after или every) по id, который вернул after()/every(). */ + cancel(id) { + if (id == null) return; + const i = _timers.findIndex(t => t.id === id); + if (i >= 0) _timers.splice(i, 1); + }, + /** + * Плавно изменить свойства объекта (твин — анимация перехода). + * ref — объект сцены (то что вернул scene.spawn / scene.find) или GUI-id. + * props — что менять и до какого значения: + * { x, y, z, rotationX, rotationY, rotationZ, sx, sy, sz, + * color: '#ff0000', opacity: 0..1 } + * opts — { duration: 1 (сек), easing: 'linear'|'ease'|'bounce'|'elastic'|'back', + * delay: 0, repeat: 0 (раз; -1 = бесконечно), yoyo: false, + * onDone: fn } + * Возвращает tweenId — анимацию можно прервать через game.cancelTween(id). + * + * // плавно открыть дверь за 1 секунду + * game.tween(door, { rotationY: Math.PI/2 }, { duration: 1, easing: 'ease' }); + * // пульсирующая монетка + * game.tween(coin, { sy: 1.4 }, { duration: 0.5, yoyo: true, repeat: -1 }); + */ + tween(ref, props, opts) { + if (typeof ref !== 'string' || !props || typeof props !== 'object') return null; + opts = opts || {}; + const id = ++_tweenSeq; + if (typeof opts.onDone === 'function') _tweenCallbacks[id] = opts.onDone; + _send('tween.start', { + tweenId: id, + ref, + props, + duration: Number.isFinite(Number(opts.duration)) ? Number(opts.duration) : 1, + easing: typeof opts.easing === 'string' ? opts.easing : 'ease', + delay: Number.isFinite(Number(opts.delay)) ? Number(opts.delay) : 0, + repeat: Number.isFinite(Number(opts.repeat)) ? Number(opts.repeat) : 0, + yoyo: !!opts.yoyo, + }); + return id; + }, + /** Прервать твин по id, который вернул game.tween(). */ + cancelTween(id) { + if (id == null) return; + delete _tweenCallbacks[id]; + _send('tween.cancel', { tweenId: id }); + }, + /** + * Подписаться на нажатие клавиши. + * key — буква 'w', 'a', 's', 'd' или специальные имена 'space', 'shift', + * 'enter', 'escape', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'. + * Сравнение case-insensitive. Если key не передан — fn вызывается на любую клавишу. + */ + onKey(key, fn) { + if (typeof key === 'function') { fn = key; key = '*'; } + if (typeof fn !== 'function') return; + const k = String(key).toLowerCase(); + (_globalKeyDownHandlers[k] = _globalKeyDownHandlers[k] || []).push(fn); + }, + /** То же что onKey, но на отпускание клавиши. */ + onKeyUp(key, fn) { + if (typeof key === 'function') { fn = key; key = '*'; } + if (typeof fn !== 'function') return; + const k = String(key).toLowerCase(); + (_globalKeyUpHandlers[k] = _globalKeyUpHandlers[k] || []).push(fn); + }, + /** + * Глобальный клик в Play-режиме. event = {point, target}. + * target = null если клик прошёл мимо объектов. + */ + onClick(fn) { + if (typeof fn === 'function') _globalClickHandlers.push(fn); + }, + /** + * Игрок коснулся любого объекта (с target-скриптом или без — + * для глобального события событие шлётся ВСЕГДА). + * event = {target}. + */ + onPlayerTouch(fn) { + if (typeof fn === 'function') _globalTouchHandlers.push(fn); + }, + /** + * Моб убит игроком (или другим способом). + * fn({mobType, position}). mobType: 'zombie' | ... + */ + onMobKilled(fn) { + if (typeof fn === 'function') _mobKilledHandlers.push(fn); + }, + /** + * Любой NPC погиб (hp дошёл до 0). fn({id, position}). + * Для конкретного NPC удобнее npc.onDeath(fn) на объекте-NPC. + */ + onNpcDeath(fn) { + if (typeof fn === 'function') _globalNpcDeathHandlers.push(fn); + }, + /** + * Игрок присоединился к комнате (Фаза 4.3). fn({sessionId, name}). + */ + onPlayerJoin(fn) { + if (typeof fn === 'function') _playerJoinHandlers.push(fn); + }, + /** + * Катсцена камеры доиграла (Фаза 5.7). fn() — без аргументов. + * game.camera.cutscene([...]); + * game.onCutsceneDone(() => game.camera.reset()); + */ + onCutsceneDone(fn) { + if (typeof fn === 'function') _cutsceneDoneHandlers.push(fn); + }, + /** Игрок покинул комнату. fn({sessionId, name}). */ + onPlayerLeave(fn) { + if (typeof fn === 'function') _playerLeaveHandlers.push(fn); + }, + /** + * Подписаться на адресное сообщение (Фаза 4.3). fn({from, data}). + * game.onMessage('подарок', (msg) => game.ui.showText('от ' + msg.from)); + */ + onMessage(name, fn) { + if (typeof name !== 'string' || typeof fn !== 'function') return; + (_mpMessageHandlers[name] = _mpMessageHandlers[name] || []).push(fn); + }, + /** + * Отправить сообщение игроку (Фаза 4.3). + * game.sendTo(player, 'подарок', { gold: 100 }); + * player — объект из game.players.* или его sessionId. + */ + sendTo(player, name, data) { + if (typeof name !== 'string') return; + const sessionId = typeof player === 'string' + ? player + : (player && player.sessionId); + if (!sessionId) return; + _send('mp.sendTo', { sessionId, name, data }); + }, + /** + * Подписаться на изменение HP игрока (получение урона / лечение / смерть). + * fn(event) где event = { hp, maxHp, source, damaged, delta }. + * - source — строка ('script', 'zombie', 'fall', 'lava', ...) или null. + * - delta — изменение HP (отрицательное = урон, положительное = лечение). + * - damaged — true если это был урон. + */ + onHpChange(fn) { + if (typeof fn === 'function') _hpChangeHandlers.push(fn); + }, + /** + * Игрок погиб (hp дошло до 0). Срабатывает один раз на смерть. + * game.onPlayerDied(() => game.ui.showText('Игра окончена', 3)); + */ + onPlayerDied(fn) { + if (typeof fn === 'function') _playerDiedHandlers.push(fn); + }, + /** Игрок прыгнул. */ + onPlayerJump(fn) { + if (typeof fn === 'function') _playerJumpHandlers.push(fn); + }, + /** Игрок приземлился (коснулся земли после полёта/прыжка). */ + onPlayerLand(fn) { + if (typeof fn === 'function') _playerLandHandlers.push(fn); + }, + /** + * UI / HUD — текст и счётчики поверх viewport в Play. + * game.ui.showText('Привет', 2) — флешит текст в центре + * game.ui.score = 100 — счётчик в углу + * game.ui.timer = 60 — таймер + * game.ui.set('hp', 'HP: 100', {color}) — произвольная именованная метка + * game.ui.remove('hp') + * game.ui.clear() — убрать всё + */ + ui: (() => { + const _state = { score: null, timer: null }; + return { + get score() { return _state.score; }, + set score(v) { _state.score = v; _send('ui.set', { id: '__score', text: v == null ? null : 'Очки: ' + v }); }, + get timer() { return _state.timer; }, + set timer(v) { + _state.timer = v; + if (v == null) { _send('ui.set', { id: '__timer', text: null }); return; } + const n = Number(v); + if (!Number.isFinite(n)) return; + const mm = Math.floor(Math.max(0, n) / 60); + const ss = Math.floor(Math.max(0, n) % 60); + const txt = (mm < 10 ? '0' : '') + mm + ':' + (ss < 10 ? '0' : '') + ss; + _send('ui.set', { id: '__timer', text: txt }); + }, + /** Кратковременный текст по центру экрана. seconds=2 по умолчанию. */ + showText(text, seconds) { + _send('ui.flash', { + text: String(text == null ? '' : text), + seconds: Number.isFinite(Number(seconds)) ? Number(seconds) : 2, + }); + }, + /** + * Установить произвольную метку. + * id — уникальное имя для последующих обновлений и remove. + * opts: { x, y } — позиция в процентах (0..100), { color, size } — стилизация. + */ + set(id, text, opts) { + if (typeof id !== 'string' || !id) return; + _send('ui.set', { + id, + text: text == null ? null : String(text), + opts: opts || null, + }); + }, + /** Убрать метку по id. */ + remove(id) { + if (typeof id !== 'string' || !id) return; + _send('ui.set', { id, text: null }); + }, + /** Убрать весь HUD. */ + clear() { + _state.score = null; + _state.timer = null; + _send('ui.clear', null); + }, + }; + })(), + /** API сцены: spawn/delete/find/all. */ + scene: { + /** + * Создать объект на сцене. + * type: 'block:' / 'primitive:' / 'model:' / 'light:point'. + * opts: { x, y, z, sx, sy, sz, color, material, rotationY, name, lifetime, + * brightness, range }. + * lifetime — если задан (секунды), объект сам удалится через это время. + * brightness/range — только для 'light:point' (яркость и радиус лампы). + * Возвращает строку-ref (можно использовать в delete/getPosition). + */ + spawn(type, opts) { + if (typeof type !== 'string') return null; + opts = opts || {}; + // Алиас: 'light:point' — это примитив-лампа. + if (type === 'light:point' || type === 'light') type = 'primitive:light'; + const x = Number(opts.x) || 0; + const y = Number(opts.y) || 0; + const z = Number(opts.z) || 0; + const colon = type.indexOf(':'); + if (colon < 0) return null; + const kind = type.slice(0, colon); + const subType = type.slice(colon + 1); + if (kind === 'block') { + const ix = Math.round(x), iy = Math.round(y), iz = Math.round(z); + const ref = 'block:' + ix + ',' + iy + ',' + iz; + // color — для окрашиваемых блоков (studs-block, задача 09). + _send('scene.spawn', { kind: 'block', subType, x: ix, y: iy, z: iz, ref, color: opts.color }); + if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime); + return ref; + } + if (kind === 'primitive' || kind === 'model') { + _localRefSeq++; + const ref = kind + ':_local_' + _localRefSeq; + _send('scene.spawn', { + kind, subType, x, y, z, + sx: opts.sx, sy: opts.sy, sz: opts.sz, + color: opts.color, material: opts.material, + rotationY: opts.rotationY, + name: opts.name, + brightness: opts.brightness, range: opts.range, + effect: opts.effect, + // anchored:false → объект падает (физика). По умолчанию + // примитив заякорен (anchored:true) и висит на месте. + anchored: opts.anchored, + // canCollide — можно сделать объект проходимым (зона). + canCollide: opts.canCollide, + // visible:false → объект скрыт (показать через setVisible). + visible: opts.visible, + // textureAsset — id картинки из ассетов проекта на грани. + textureAsset: opts.textureAsset, + ref, + }); + if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime); + return ref; + } + // Пользовательская модель из воксельного редактора моделей. + // type = 'user:', где — числовой id модели в проекте. + // ref пользовательских инстансов в сцене — 'usermodel:'. + if (kind === 'user') { + _localRefSeq++; + const ref = 'usermodel:_local_' + _localRefSeq; + _send('scene.spawn', { + kind: 'userModel', + // subType — это полная строка 'user:' (как принимает + // UserModelManager.addInstance). Восстанавливаем её. + subType: 'user:' + subType, + x, y, z, + rotationY: opts.rotationY, + scale: opts.scale, + name: opts.name, + ref, + }); + if (opts.lifetime != null) _scheduleDelete(ref, opts.lifetime); + return ref; + } + return null; + }, + /** Удалить объект по ref. */ + delete(ref) { + if (typeof ref !== 'string' || !ref) return; + _send('scene.delete', { ref }); + }, + /** + * Удалить объект через seconds секунд (авто-удаление). + * const p = game.scene.spawn('primitive:cube', { x, y, z }); + * game.scene.deleteAfter(p, 5); // исчезнет через 5 секунд + */ + deleteAfter(ref, seconds) { + _scheduleDelete(ref, seconds); + }, + /** + * Переместить объект (для моделей/примитивов) — без target-скрипта. + * ref — то что вернул spawn() или scene.find(). + */ + move(ref, x, y, z) { + if (typeof ref !== 'string') return; + const nx = Number(x), ny = Number(y), nz = Number(z); + if (!Number.isFinite(nx) || !Number.isFinite(ny) || !Number.isFinite(nz)) return; + // Парсим ref: 'primitive:_local_3' или 'primitive:realId' или 'model:id' + const colon = ref.indexOf(':'); + if (colon < 0) return; + const kind = ref.slice(0, colon); + const id = ref.slice(colon + 1); + if (kind !== 'primitive' && kind !== 'model') return; + _send('self.move', { + target: { kind, id, ref: id }, + x: nx, y: ny, z: nz, + }); + }, + /** + * Повернуть объект вокруг Y (в радианах). Только для примитивов. + */ + rotate(ref, ry) { + if (typeof ref !== 'string') return; + const r = Number(ry); + if (!Number.isFinite(r)) return; + const colon = ref.indexOf(':'); + if (colon < 0) return; + const kind = ref.slice(0, colon); + const id = ref.slice(colon + 1); + if (kind !== 'primitive') return; + _send('scene.rotate', { kind, id, rotationY: r }); + }, + /** + * Установить полный поворот (rx, ry, rz) в радианах. Для примитивов. + * Нужно для Кубикон Dash: куб крутится вокруг Z в воздухе. + */ + setRotation(ref, rx, ry, rz) { + if (typeof ref !== 'string') return; + const x = Number(rx), y = Number(ry), z = Number(rz); + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return; + const colon = ref.indexOf(':'); + if (colon < 0) return; + const kind = ref.slice(0, colon); + const id = ref.slice(colon + 1); + if (kind !== 'primitive') return; + _send('scene.setRotation', { id, rx: x, ry: y, rz: z }); + }, + /** + * Изменить collision примитива (true = твёрдый, false = проваливается). + */ + setCollide(ref, can) { + if (typeof ref !== 'string') return; + const colon = ref.indexOf(':'); + if (colon < 0) return; + const kind = ref.slice(0, colon); + const id = ref.slice(colon + 1); + if (kind !== 'primitive') return; + _send('scene.setCollide', { id, canCollide: !!can }); + }, + /** + * Изменить видимость примитива/модели (true = видно, false = скрыт). + */ + setVisible(ref, vis) { + if (typeof ref !== 'string') return; + const colon = ref.indexOf(':'); + if (colon < 0) return; + const kind = ref.slice(0, colon); + const id = ref.slice(colon + 1); + if (kind !== 'primitive' && kind !== 'model') return; + _send('scene.setVisible', { kind, id, visible: !!vis }); + }, + /** + * Изменить цвет примитива (hex-строка типа '#ff0000'). + */ + setColor(ref, color) { + if (typeof ref !== 'string' || typeof color !== 'string') return; + const colon = ref.indexOf(':'); + if (colon < 0) return; + const kind = ref.slice(0, colon); + const id = ref.slice(colon + 1); + if (kind !== 'primitive') return; + _send('scene.setColor', { id, color }); + }, + /** + * Повесить текст-метку НАД объектом (имя/HP над персонажем, врагом). + * Метка всегда повёрнута к камере и видна поверх геометрии. + * game.scene.setLabel(enemy, 'Босс HP: 100', { color: '#ff4444' }); + * opts: { color: '#fff', height: 2.5 (м над объектом), size: 1 }. + * Работает для примитивов и моделей. + */ + setLabel(ref, text, opts) { + if (typeof ref !== 'string') return; + _send('scene.setLabel', { + ref, + text: String(text == null ? '' : text), + opts: opts || {}, + }); + }, + /** Убрать метку с объекта. */ + clearLabel(ref) { + if (typeof ref !== 'string') return; + _send('scene.clearLabel', { ref }); + }, + /** + * Прозрачность примитива: 1 = непрозрачно, 0 = полностью невидимо. + * Для плавного исчезновения используй game.tween(ref, {opacity: 0}, ...). + */ + setOpacity(ref, value) { + if (typeof ref !== 'string') return; + const v = Number(value); + if (!Number.isFinite(v)) return; + if (ref.indexOf('primitive:') !== 0) return; + // шлём ПОЛНЫЙ ref — GameRuntime._resolvePrimitiveId резолвит + // локальный ref ('primitive:_local_N') через _localToReal. + _send('scene.setOpacity', { ref, opacity: Math.max(0, Math.min(1, v)) }); + }, + /** + * Масштаб примитива по осям. 1 = обычный размер, 2 = вдвое больше. + * Можно передать одно число (одинаково по всем осям) или три. + * game.scene.setScale(box, 2); // куб ×2 + * game.scene.setScale(box, 1, 3, 1); // вытянуть по Y + */ + setScale(ref, sx, sy, sz) { + if (typeof ref !== 'string') return; + let nx = Number(sx); + if (!Number.isFinite(nx) || nx <= 0) return; + let ny = Number(sy), nz = Number(sz); + // один аргумент → одинаково по всем осям + if (!Number.isFinite(ny)) ny = nx; + if (!Number.isFinite(nz)) nz = nx; + if (ref.indexOf('primitive:') !== 0) return; + _send('scene.setScale', { ref, sx: nx, sy: ny, sz: nz }); + }, + /** + * Материал примитива: 'default' | 'metal' | 'glass' | 'neon'. + * game.scene.setMaterial(box, 'neon'); // куб светится + */ + setMaterial(ref, name) { + if (typeof ref !== 'string' || typeof name !== 'string') return; + if (ref.indexOf('primitive:') !== 0) return; + _send('scene.setMaterial', { ref, material: name }); + }, + /** + * Создать копию примитива со смещением. Возвращает ref новой копии. + * const copy = game.scene.clone(box, { dx: 3 }); // копия на 3 правее + * offset: { dx, dy, dz } — смещение относительно оригинала. + */ + clone(ref, offset) { + if (typeof ref !== 'string') return null; + if (ref.indexOf('primitive:') !== 0) return null; + offset = offset || {}; + _localRefSeq++; + const newRef = 'primitive:_local_' + _localRefSeq; + _send('scene.clone', { + ref, + newRef, + dx: Number(offset.dx) || 0, + dy: Number(offset.dy) || 0, + dz: Number(offset.dz) || 0, + }); + return newRef; + }, + /** + * Установить динамическую текстуру примитива из dataURL. + * dataUrl — base64 PNG (например, из canvas.toDataURL()). + * Используется для GD-скинов: canvas-фабрика рисует лицо куба → шлёт сюда. + */ + setTexture(ref, dataUrl) { + if (typeof ref !== 'string' || typeof dataUrl !== 'string') return; + const colon = ref.indexOf(':'); + if (colon < 0) return; + const kind = ref.slice(0, colon); + const id = ref.slice(colon + 1); + if (kind !== 'primitive') return; + _send('scene.setTexture', { id, dataUrl }); + }, + /** + * Установить АБСОЛЮТНЫЙ угол поворота папки вокруг точки pivot (XZ). + * Все примитивы внутри папки повернутся как единое целое. + * game.scene.setFolderYaw('Голова куклы', Math.PI, { x: 0, z: 90 }); + */ + setFolderYaw(folderName, angle, pivot) { + if (typeof folderName !== 'string') return; + const a = Number(angle); + if (!Number.isFinite(a)) return; + if (!pivot || !Number.isFinite(Number(pivot.x)) + || !Number.isFinite(Number(pivot.z))) return; + _send('scene.setFolderYaw', { + folderName, + angle: a, + pivot: { x: Number(pivot.x), z: Number(pivot.z) }, + }); + }, + /** + * Найти объекты по name. Возвращает массив Instance-прокси (паритет + * со студией). Instance coerces в строку-ref, поэтому код, принимавший + * строковый ref, продолжает работать. + */ + find(name) { + const out = []; + const n = String(name || '').toLowerCase(); + for (const m of _sceneIndex.models) { + if (m.name && String(m.name).toLowerCase() === n) { + out.push(_getOrCreateInstance(m.ref, 'model')); + } + } + for (const p of _sceneIndex.primitives) { + if (p.name && String(p.name).toLowerCase() === n) { + out.push(_getOrCreateInstance(p.ref, 'primitive')); + } + } + return out; + }, + /** Первый объект с таким name или null. */ + findOne(name) { + const arr = this.find(name); + return arr.length > 0 ? arr[0] : null; + }, + /** Список Instance всех объектов заданного типа: 'block' | 'model' | 'primitive'. */ + all(kind) { + if (kind === 'block') return _sceneIndex.blocks.map(b => _getOrCreateInstance(b.ref, 'block')); + if (kind === 'model') return _sceneIndex.models.map(m => _getOrCreateInstance(m.ref, 'model')); + if (kind === 'primitive') return _sceneIndex.primitives.map(p => _getOrCreateInstance(p.ref, 'primitive')); + return []; + }, + /** + * Сохранить произвольное значение НА объекте (атрибут). + * Видно всем скриптам — скрипт двери ставит, скрипт ключа читает. + * game.scene.setData(door, 'locked', true); + * game.scene.setData(chest, 'gold', 100); + */ + setData(ref, key, value) { + if (typeof ref !== 'string' || typeof key !== 'string') return; + // оптимистично обновляем локальное зеркало (до прихода снапшота) + if (!_dataIndex[ref]) _dataIndex[ref] = {}; + _dataIndex[ref][key] = value; + _send('scene.setData', { ref, key, value }); + }, + /** + * Прочитать атрибут объекта. Возвращает значение или undefined. + * if (game.scene.getData(door, 'locked')) { ... } + */ + getData(ref, key) { + if (typeof ref !== 'string' || typeof key !== 'string') return undefined; + const bag = _dataIndex[ref]; + return bag ? bag[key] : undefined; + }, + /** + * Теги объектов (Фаза 5.6) — как CollectionService в Roblox. + * Помечаешь объекты тегом, потом находишь все объекты с тегом. + * game.scene.tag(enemy, 'враг'); + * for (const e of game.scene.getTagged('враг')) { ... } + */ + tag(ref, tag) { + if (typeof ref !== 'string' || typeof tag !== 'string' || !tag) return; + // Оптимистично обновляем локальное зеркало (до прихода снапшота). + if (!_dataIndex[ref]) _dataIndex[ref] = {}; + const cur = Array.isArray(_dataIndex[ref].__tags) ? _dataIndex[ref].__tags : []; + if (!cur.includes(tag)) _dataIndex[ref].__tags = [...cur, tag]; + _send('scene.tag', { ref, tag }); + }, + /** Снять тег с объекта. */ + untag(ref, tag) { + if (typeof ref !== 'string' || typeof tag !== 'string' || !tag) return; + if (_dataIndex[ref] && Array.isArray(_dataIndex[ref].__tags)) { + _dataIndex[ref].__tags = _dataIndex[ref].__tags.filter(t => t !== tag); + } + _send('scene.untag', { ref, tag }); + }, + /** True если у объекта есть такой тег. */ + hasTag(ref, tag) { + if (typeof ref !== 'string' || typeof tag !== 'string') return false; + const bag = _dataIndex[ref]; + return !!(bag && Array.isArray(bag.__tags) && bag.__tags.includes(tag)); + }, + /** Список ref всех объектов с заданным тегом. */ + getTagged(tag) { + if (typeof tag !== 'string' || !tag) return []; + const out = []; + for (const ref of Object.keys(_dataIndex)) { + const bag = _dataIndex[ref]; + if (bag && Array.isArray(bag.__tags) && bag.__tags.includes(tag)) { + out.push(ref); + } + } + return out; + }, + /** Позиция объекта по ref или null. Работает и с локальным ref + * от scene.spawn (резолвит в реальный через _spawnLocalToReal). */ + getPosition(ref) { + if (typeof ref !== 'string') return null; + // Локальный ref scene.spawn → реальный. + const r = _spawnLocalToReal[ref] || ref; + for (const b of _sceneIndex.blocks) if (b.ref === r) return { x: b.x, y: b.y, z: b.z }; + for (const m of _sceneIndex.models) if (m.ref === r) return { x: m.x, y: m.y, z: m.z }; + for (const p of _sceneIndex.primitives) if (p.ref === r) return { x: p.x, y: p.y, z: p.z }; + return null; + }, + /** + * Создать NPC — управляемого скриптом персонажа (Фаза 4.1). + * modelType — id модели (как в game.scene.spawn('model:...')). + * opts: { x, y, z, rotationY, hp, name, speed }. + * Возвращает объект-NPC с методами: + * npc.moveTo(x, z) — идти в точку + * npc.follow(ref) — следовать за объектом ('player' или ref) + * npc.stop() — остановиться + * npc.say(text, sec) — реплика над головой + * npc.damage(amount) — нанести урон + * npc.remove() — убрать со сцены + * npc.onDeath(fn) — колбэк при гибели NPC + * npc.position — {x,y,z} (актуальная позиция) + * npc.hp / npc.name — текущие значения + * npc.ref — строковый ref NPC + * + * const trader = game.scene.spawnNpc('robot', { x: 5, z: 0, name: 'Боб' }); + * trader.say('Привет!'); + * trader.follow('player'); + */ + spawnNpc(modelType, opts) { + if (typeof modelType !== 'string') return null; + opts = opts || {}; + _npcRefSeq++; + const ref = 'npc:_local_' + _npcRefSeq; + _send('npc.spawn', { + modelType, ref, + x: Number(opts.x) || 0, + y: Number(opts.y) || 0, + z: Number(opts.z) || 0, + rotationY: Number(opts.rotationY) || 0, + hp: Number.isFinite(Number(opts.hp)) ? Number(opts.hp) : undefined, + name: typeof opts.name === 'string' ? opts.name : undefined, + speed: Number.isFinite(Number(opts.speed)) ? Number(opts.speed) : undefined, + }); + return _makeNpcProxy(ref); + }, + /** Список всех NPC на сцене — массив объектов {id, name, x,y,z, hp, ...}. */ + npcs() { + return _npcs.map(n => ({ ...n })); + }, + /** + * Эффект частиц в точке. Авто-удаляется через duration секунд. + * type: 'fire' | 'smoke' | 'sparks' | 'magic' | 'explosion' | 'confetti' + * position: {x,y,z} + * options: { duration: 2 (сек), count: 50 (множитель), color: '#ffaa00' } + */ + spawnParticles(type, position, options) { + if (typeof type !== 'string' || !position) return; + _send('scene.particles', { + type, + position: { + x: Number(position.x) || 0, + y: Number(position.y) || 0, + z: Number(position.z) || 0, + }, + duration: options && Number.isFinite(Number(options.duration)) ? Number(options.duration) : 1.5, + count: options && Number.isFinite(Number(options.count)) ? Number(options.count) : 1, + color: options?.color || null, + }); + }, + /** + * Снимок всех живых мобов (зомби и т.д.). Обновляется каждый tick. + * Возвращает массив { id, mobType, x, y, z, hp }. + * Опциональный фильтр: { mobType?: 'zombie', within?: { x, y?, z, radius } } + */ + mobs(filter) { + const arr = _mobs.slice(); + if (!filter) return arr; + const wantType = typeof filter.mobType === 'string' ? filter.mobType : null; + const within = filter.within; + if (wantType == null && !within) return arr; + const out = []; + const wx = within ? Number(within.x) || 0 : 0; + const wz = within ? Number(within.z) || 0 : 0; + const wr2 = within ? (Number(within.radius) || 0) ** 2 : 0; + for (const m of arr) { + if (wantType && m.mobType !== wantType) continue; + if (within) { + const dx = m.x - wx, dz = m.z - wz; + if (dx*dx + dz*dz > wr2) continue; + } + out.push(m); + } + return out; + }, + /** + * Убить моба (или массив мобов). Принимает объект из mobs() или его id. + * Запускает обычную смерть (с эффектами + onMobKilled). + */ + killMob(target) { + if (target == null) return; + const items = Array.isArray(target) ? target : [target]; + for (const it of items) { + let id = null; + if (typeof it === 'number') id = it; + else if (it && typeof it === 'object' && 'id' in it) id = Number(it.id); + if (Number.isFinite(id)) _send('mob.kill', { id }); + } + }, + /** + * Высота поверхности гладкого ландшафта в точке (x, z). + * Билинейная интерполяция по карте высот (raycast по реальному + * мешу, снятой при старте). Нужно чтобы скрипты ставили объекты + * (животных и т.п.) ТОЧНО на землю, а не парили/тонули. + * + * Возвращает Y поверхности, или null если карта высот не пришла + * (нет гладкого ландшафта в проекте). + * + * const y = game.scene.surfaceY(p.x, p.z); + * if (y !== null) game.self.move(nx, y, nz); + */ + surfaceY(x, z) { + const hm = _terrainHM; + if (!hm || !hm.heights) return null; + const nx = Number(x), nz = Number(z); + if (!Number.isFinite(nx) || !Number.isFinite(nz)) return null; + const fx = (nx - hm.origin.x) / hm.step; + const fz = (nz - hm.origin.z) / hm.step; + let c0 = Math.floor(fx), r0 = Math.floor(fz); + // clamp в пределы карты + if (c0 < 0) c0 = 0; if (c0 > hm.cols - 2) c0 = hm.cols - 2; + if (r0 < 0) r0 = 0; if (r0 > hm.rows - 2) r0 = hm.rows - 2; + const tx = Math.max(0, Math.min(1, fx - c0)); + const tz = Math.max(0, Math.min(1, fz - r0)); + const H = hm.heights; + const W = hm.cols; + const h00 = H[r0 * W + c0]; + const h10 = H[r0 * W + c0 + 1]; + const h01 = H[(r0 + 1) * W + c0]; + const h11 = H[(r0 + 1) * W + c0 + 1]; + // null-ячейки заменяем на среднее валидных + const vals = []; + if (h00 != null) vals.push(h00); + if (h10 != null) vals.push(h10); + if (h01 != null) vals.push(h01); + if (h11 != null) vals.push(h11); + if (vals.length === 0) return null; + const avg = vals.reduce((a, b) => a + b, 0) / vals.length; + const v00 = h00 != null ? h00 : avg; + const v10 = h10 != null ? h10 : avg; + const v01 = h01 != null ? h01 : avg; + const v11 = h11 != null ? h11 : avg; + const a = v00 * (1 - tx) + v10 * tx; + const b = v01 * (1 - tx) + v11 * tx; + return a * (1 - tz) + b * tz; + }, + }, + + /** + * Физика — луч (raycast), импульсы, взрывы. + */ + physics: { + /** + * Пустить луч из точки origin в направлении dir. + * Возвращает { hit, ref, point, distance } — hit=true если во что-то + * попали. ref — объект (primitive) в который попали. + * Синхронный — можно звать прямо в onClick для стрельбы. + * const r = game.physics.raycast(game.player.position, game.player.forward); + * if (r.hit) game.scene.delete(r.ref); + * opts: { maxDistance: 100, ignore: [ref, ...] } + */ + raycast(origin, dir, opts) { + opts = opts || {}; + const ox = Number(origin?.x), oy = Number(origin?.y), oz = Number(origin?.z); + let dx = Number(dir?.x), dy = Number(dir?.y), dz = Number(dir?.z); + if (![ox, oy, oz, dx, dy, dz].every(Number.isFinite)) { + return { hit: false, ref: null, point: null, distance: Infinity }; + } + // нормализуем направление + const dlen = Math.sqrt(dx*dx + dy*dy + dz*dz) || 1; + dx /= dlen; dy /= dlen; dz /= dlen; + const maxDist = Number.isFinite(Number(opts.maxDistance)) ? Number(opts.maxDistance) : 100; + const ignore = Array.isArray(opts.ignore) ? opts.ignore : []; + let best = { hit: false, ref: null, point: null, distance: Infinity }; + // перебираем примитивы — ray vs AABB (с учётом поворота вокруг Y) + for (const p of _sceneIndex.primitives) { + if (p.visible === false) continue; + if (ignore.includes(p.ref)) continue; + const hw = (p.sx || 1) / 2, hh = (p.sy || 1) / 2, hd = (p.sz || 1) / 2; + // переводим луч в локальные координаты примитива (обратный поворот по Y) + const ang = -(p.rotationY || 0); + const cos = Math.cos(ang), sin = Math.sin(ang); + const rx = ox - p.x, rz = oz - p.z; + const lox = rx * cos - rz * sin; + const loz = rx * sin + rz * cos; + const loy = oy - p.y; + const ldx = dx * cos - dz * sin; + const ldz = dx * sin + dz * cos; + const ldy = dy; + // slab-тест ray vs AABB + const t = _rayAabb(lox, loy, loz, ldx, ldy, ldz, hw, hh, hd); + if (t != null && t >= 0 && t <= maxDist && t < best.distance) { + best = { + hit: true, ref: p.ref, distance: t, + point: { x: ox + dx*t, y: oy + dy*t, z: oz + dz*t }, + }; + } + } + return best; + }, + /** + * Задать скорость объекту (м/с). Объект полетит с этой скоростью. + * game.physics.setVelocity(ball, { x: 0, y: 10, z: 5 }); + */ + setVelocity(ref, vel) { + if (typeof ref !== 'string' || !vel) return; + _send('physics.setVelocity', { + ref, + vx: Number(vel.x) || 0, vy: Number(vel.y) || 0, vz: Number(vel.z) || 0, + }); + }, + /** + * Толкнуть объект импульсом (резкий толчок). + * game.physics.applyImpulse(box, { x: 15, y: 5, z: 0 }); + */ + applyImpulse(ref, impulse) { + if (typeof ref !== 'string' || !impulse) return; + _send('physics.applyImpulse', { + ref, + ix: Number(impulse.x) || 0, iy: Number(impulse.y) || 0, iz: Number(impulse.z) || 0, + }); + }, + /** + * Взрыв в точке: визуальный эффект + урон игроку и мобам в радиусе. + * game.physics.explode({ x, y, z }, 5, { damage: 40 }); + * opts: { damage: 30, force: 0 } — урон и сила отброса. + */ + explode(pos, radius, opts) { + if (!pos) return; + opts = opts || {}; + _send('physics.explode', { + x: Number(pos.x) || 0, y: Number(pos.y) || 0, z: Number(pos.z) || 0, + radius: Number(radius) || 3, + damage: Number.isFinite(Number(opts.damage)) ? Number(opts.damage) : 30, + force: Number(opts.force) || 0, + }); + }, + /** + * Проходимость объекта или группы (Фаза 5.9, collision groups). + * target — ref объекта ИЛИ тег (тогда применяется ко ВСЕМ объектам + * с этим тегом — теги работают как collision groups). + * on=true — игрок проходит сквозь (объект остаётся видимым), + * on=false — снова твёрдый. + * game.physics.passThrough(wall, true); // одна стена + * game.physics.passThrough('призраки', true); // вся группа по тегу + */ + passThrough(target, on) { + if (typeof target !== 'string' || !target) return; + _send('physics.passThrough', { target, on: !!on }); + }, + }, + + /** + * GUI — управление 2D-интерфейсом (Frame/Text/Button/Image) из скриптов. + */ + gui: { + /** Найти ID элемента по имени. Возвращает строку или null. */ + find(name) { + if (typeof name !== 'string') return null; + const n = name.toLowerCase(); + for (const g of _guiIndex) { + if (g.name && String(g.name).toLowerCase() === n) return g.id; + } + return null; + }, + /** Список ID всех элементов. */ + all() { + return _guiIndex.map(g => g.id); + }, + /** Получить копию свойств элемента. */ + get(id) { + if (typeof id !== 'string') return null; + const found = _guiIndex.find(g => g.id === id); + return found ? { ...found } : null; + }, + /** + * Изменить свойства элемента. + * game.gui.update('gui_xxx', { text: 'Hi', textColor: '#ff0' }); + */ + update(id, patch) { + if (typeof id !== 'string' || !patch || typeof patch !== 'object') return; + _send('gui.update', { id, patch }); + }, + /** Сделать элемент видимым. */ + show(id) { + if (typeof id !== 'string') return; + _send('gui.update', { id, patch: { visible: true } }); + }, + /** Скрыть элемент (но не удалять). */ + hide(id) { + if (typeof id !== 'string') return; + _send('gui.update', { id, patch: { visible: false } }); + }, + /** + * Создать новый элемент. Возвращает локальный ref-id (строку). + * const id = game.gui.create('text', { x: 50, y: 10, text: 'HP: 100' }); + */ + create(type, opts) { + if (typeof type !== 'string') return null; + _localRefSeq++; + const localRef = '_gui_local_' + _localRefSeq; + _send('gui.create', { type, opts: opts || {}, localRef }); + return localRef; + }, + /** Удалить элемент по id. */ + remove(id) { + if (typeof id !== 'string') return; + _send('gui.remove', { id }); + }, + /** + * Подписаться на клик по кнопке (по id). + * game.gui.onClick('gui_xxx', () => { game.log('clicked!'); }); + */ + onClick(id, fn) { + if (typeof id !== 'string' || typeof fn !== 'function') return; + (_guiClickHandlers[id] = _guiClickHandlers[id] || []).push(fn); + }, + /** + * Подписаться на ввод в поле TextBox — срабатывает когда игрок + * нажал Enter. fn получает введённый текст. + * game.gui.onSubmit('gui_name', (text) => { + * game.ui.showText('Привет, ' + text); + * }); + */ + onSubmit(id, fn) { + if (typeof id !== 'string' || typeof fn !== 'function') return; + (_guiSubmitHandlers[id] = _guiSubmitHandlers[id] || []).push(fn); + }, + /** Задача 03: tween свойства GUI-элемента. + * props: { x, y, w, h, rotation, scaleX, scaleY, bgOpacity, textSize, + * bgColor, textColor, borderColor } (любое числовое или hex-цвет). + * opts: { duration, easing, delay, repeat, reverses, onDone } */ + tween(id, props, opts) { + if (typeof id !== 'string' || !id) return null; + if (!props || typeof props !== 'object') return null; + opts = opts || {}; + const tid = ++_tweenSeq; + if (typeof opts.onDone === 'function') _tweenCallbacks[tid] = opts.onDone; + _send('gui.tween', { + tweenId: tid, id, props, + duration: Number.isFinite(Number(opts.duration)) ? Number(opts.duration) : 0.5, + easing: typeof opts.easing === 'string' ? opts.easing : 'ease', + delay: Number.isFinite(Number(opts.delay)) ? Number(opts.delay) : 0, + repeat: Number.isFinite(Number(opts.repeat)) ? Number(opts.repeat) : 0, + reverses: !!opts.reverses, + }); + return tid; + }, + /** Отменить tween по id (возвращённому из game.gui.tween). */ + cancelTween(tweenId) { + if (!Number.isFinite(tweenId)) return; + _send('gui.cancelTween', { tweenId }); + delete _tweenCallbacks[tweenId]; + }, + }, + /** + * Камера — тряска, FOV, привязка к объекту, катсцены (Фаза 5.7). + */ + camera: { + /** + * Тряска камеры. amp в метрах (0.1 = чуть-чуть, 0.5 = сильно), + * dur в секундах. Затухает к 0. + */ + shake(amp, dur) { + const a = Number(amp), d = Number(dur); + if (!Number.isFinite(a) || !Number.isFinite(d) || a <= 0 || d <= 0) return; + _send('camera.shake', { amp: a, dur: d }); + }, + /** + * Угол обзора камеры (FOV) в градусах. 70 — норма, 90 — широкий, + * 40 — «зум». Диапазон 10..130. + * game.camera.setFov(90); + */ + setFov(degrees) { + const d = Number(degrees); + if (Number.isFinite(d)) _send('camera.fov', { degrees: d }); + }, + /** + * Привязать камеру к объекту — она следит за ним. + * ref — объект сцены. opts: { distance, height } — отступ камеры. + * game.camera.focusOn(bossRef, { distance: 12, height: 6 }); + */ + focusOn(ref, opts) { + if (typeof ref !== 'string') return; + opts = opts || {}; + _send('camera.focus', { + ref, + distance: Number.isFinite(Number(opts.distance)) ? Number(opts.distance) : undefined, + height: Number.isFinite(Number(opts.height)) ? Number(opts.height) : undefined, + }); + }, + /** + * Катсцена — плавный пролёт камеры по точкам. + * points — массив позиций камеры [{x,y,z}, ...]. + * opts: { lookAt: [{x,y,z}, ...] — точки взгляда (по одной на + * позицию), segDuration: секунд на отрезок }. + * game.camera.cutscene( + * [{x:0,y:10,z:-20}, {x:0,y:5,z:0}], + * { lookAt: [{x:0,y:0,z:0}, {x:0,y:0,z:0}], segDuration: 3 } + * ); + */ + cutscene(points, opts) { + if (!Array.isArray(points) || points.length < 2) return; + opts = opts || {}; + _send('camera.cutscene', { + points, + lookAt: Array.isArray(opts.lookAt) ? opts.lookAt : [], + segDuration: Number.isFinite(Number(opts.segDuration)) + ? Number(opts.segDuration) : 2, + }); + }, + /** Вернуть камеру под управление игрока. */ + reset() { + _send('camera.reset', {}); + }, + }, + /** + * Управление стандартным HUD движка (HP-бар, hotbar, кнопка меню, чат). + * Нужно для игр которые делают свой UI через game.gui.* и не хотят + * чтобы стандартные элементы мешали. + */ + hud: { + /** Скрыть/показать ВСЕ стандартные HUD-элементы. */ + setVisible(visible) { + _send('hud.setVisible', { visible: !!visible }); + }, + /** Скрыть/показать только хотбар (5 слотов инвентаря снизу). + * Для игр где инвентарь не нужен (магазин/головоломка/симулятор). */ + setHotbarVisible(visible) { + _send('hud.setHotbarVisible', { visible: !!visible }); + }, + /** Скрыть/показать только HP-индикатор (полоска жизней). */ + setHpVisible(visible) { + _send('hud.setHpVisible', { visible: !!visible }); + }, + }, + /** + * Задача 04: модальные сцены (затемнение + GUI поверх + блок ввода). + * + * Типовой кейс: boss-fight intro, открытие лутбокса, диалог с NPC, получил питомца. + * + * const m = game.modal.open({ + * darken: 0.7, // 0..1 — насколько затемнить (по умолчанию 0.5) + * darkenColor: '#000', // цвет затемнения + * target: 'scene', // 'scene' (HUD виден) | 'screen' (всё затемнено) + * blockInput: true, // блокирует WASD/Space/Ctrl (Esc/Tab/Enter работают) + * freezeCamera: true, // камера замирает + * fadeIn: 0.4, // секунды до полного затемнения + * fadeOut: 0.3, + * spotlights: [boss, h1, h2], // 3D-рефы, которые остаются яркими (вырезка mask) + * spotlightRadius: 120, // пиксели — радиус «прожектора» + * pauseSimulation: false, // ставит весь GameRuntime на паузу (мобы замирают) + * muteWorld: false, // приглушает ambient/sfx + * cameraOverride: { // фокус камеры на цель + * target: boss, distance: 8, height: 3, fov: 60, duration: 0.5, + * }, + * content: { elements: [ // временные GUI поверх модала, удалятся при close + * { kind: 'text', x: 50, y: 25, text: 'Франкенслим', textSize: 48, + * textStroke: { color: '#000', width: 3 }, textColor: '#fff' }, + * { kind: 'button', id: 'fight', x: 50, y: 80, w: 20, h: 6, text: 'В бой!' }, + * ]}, + * }); + * game.gui.onClick('fight', () => game.modal.close(m)); + * + * Готовые пресеты: + * game.modal.bossIntro(name, hp, refs) — intro босса с HP-баром + * game.modal.lootbox(items, onPick) — открытие лутбокса + * game.modal.dialog(npcName, lines, onDone) — диалог с NPC построчно + * game.modal.confirmation(title, body, onYes, onNo) — Да/Нет + * + * Только ОДИН модал одновременно. Повторный open мгновенно закрывает предыдущий. + */ + modal: { + _localSeq: 0, + _localToReal: new Map(), // localId → реальный modalId (приходит в modalOpened) + _onCloseFns: [], + open(opts) { + opts = opts || {}; + const localId = ++this._localSeq; + const replyId = '_mopen_' + localId; + _send('modal.open', { opts, replyId }); + // Возвращаем локальный id — реальный придёт асинхронно через modalOpened-event + return localId; + }, + close(modalId) { + // Резолвим локальный id → реальный. Если modalId — локальное число, но + // реальный ещё не пришёл (гонка modalOpened-event), шлём null: модал + // одиночный, null закрывает активный. Передавать локальный id нельзя — + // ModalManager.close сверяет его со своим _state.id и молча игнорит. + let real = null; + if (typeof modalId === 'number') { + real = this._localToReal.has(modalId) ? this._localToReal.get(modalId) : null; + } else if (modalId != null) { + real = modalId; // уже реальный id (строка/число от runtime) + } + _send('modal.close', { modalId: real }); + }, + update(modalId, patch) { + let real = null; + if (typeof modalId === 'number') { + real = this._localToReal.has(modalId) ? this._localToReal.get(modalId) : null; + } else if (modalId != null) { + real = modalId; + } + _send('modal.update', { modalId: real, patch: patch || {} }); + }, + isOpen() { return !!this._isOpenLocal; }, + onClose(fn) { + if (typeof fn === 'function') this._onCloseFns.push(fn); + }, + + // === Пресеты === + /** Intro босса с именем + HP-баром + кнопкой «В бой!» через delay секунд. */ + bossIntro(name, hp, refs, opts) { + opts = opts || {}; + const startBtnDelay = Number.isFinite(opts.startBtnDelay) ? opts.startBtnDelay : 2; + const buttonText = opts.buttonText || 'В бой!'; + const onStart = opts.onStart; + const elements = [ + { kind: 'text', id: '_boss_name', x: 50, y: 22, w: 60, h: 8, anchor: 'center', + text: String(name || 'Босс'), textSize: 48, fontWeight: 900, textColor: '#ffffff', + textStroke: { color: '#000', width: 3 }, bgColor: 'transparent', bgOpacity: 0, + animationPreset: 'glow' }, + { kind: 'text', id: '_boss_hp', x: 50, y: 30, w: 30, h: 6, anchor: 'center', + text: '❤ ' + hp + ' / ' + hp, textSize: 28, fontWeight: 800, textColor: '#22ff66', + textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0 }, + ]; + const m = this.open({ + darken: 0.7, target: 'scene', + blockInput: true, freezeCamera: true, + spotlights: Array.isArray(refs) ? refs : (refs ? [refs] : []), + cameraOverride: refs ? { target: Array.isArray(refs) ? refs[0] : refs, + distance: 8, height: 3, fov: 60, duration: 0.5 } : null, + content: { elements }, + }); + const _modal = this; + const _afterTid = ++_timerSeq; + _timers.push({ id: _afterTid, fn: () => { + _send('gui.create', { type: 'button', opts: { + id: '_boss_start', x: 50, y: 78, w: 20, h: 7, anchor: 'center', + text: buttonText, + bgGradient: { stops: ['#ff5454','#a00000'], angle: 90 }, + borderColor: '#000', borderWidth: 3, borderRadius: 14, + textColor: '#fff', textSize: 22, fontWeight: 900, + textStroke: { color: '#000', width: 2 }, + hover: { scale: 1.08, brightness: 1.2, duration: 0.15 }, + active: { scale: 0.94, duration: 0.08 }, + animationPreset: 'pulse', + }, localRef: '_boss_start' }); + let _started = false; + _guiClickHandlers['_boss_start'] = [() => { + if (_started) return; + _started = true; + delete _guiClickHandlers['_boss_start']; + _modal.close(m); + if (typeof onStart === 'function') { try { onStart(); } catch (e) {} } + }]; + }, delay: startBtnDelay, elapsed: 0, repeat: false }); + return m; + }, + /** Открытие лутбокса. items: [{name, color, icon, rarity}]. onPick(item). */ + lootbox(items, onPick) { + items = Array.isArray(items) ? items.slice(0, 5) : []; + const elements = [ + { kind: 'frame', id: '_lb_bg', x: 50, y: 50, w: 70, h: 50, anchor: 'center', + bgGradient: { stops: ['#1a1a3a','#0a0a1a'], angle: 135 }, + borderColor: '#ffd700', borderWidth: 3, borderRadius: 18 }, + { kind: 'text', id: '_lb_title', x: 50, y: 28, w: 60, h: 8, anchor: 'center', + text: '✨ Лутбокс ✨', textSize: 32, fontWeight: 900, textColor: '#ffd700', + textStroke: { color: '#000', width: 2 }, bgColor: 'transparent', bgOpacity: 0, + animationPreset: 'glow' }, + ]; + for (let i = 0; i < items.length; i++) { + const it = items[i]; + const x = 50 + (i - (items.length - 1) / 2) * 13; + elements.push({ + kind: 'button', id: '_lb_item_' + i, + x: x, y: 50, w: 11, h: 16, anchor: 'center', + text: (it.icon || '*') + '\\n' + (it.name || 'Приз'), + bgColor: it.color || '#3a3a5a', borderRadius: 12, + borderColor: '#ffd700', borderWidth: 2, + textColor: '#fff', textSize: 14, fontWeight: 700, + hover: { scale: 1.1, brightness: 1.3, duration: 0.15 }, + active: { scale: 0.94, duration: 0.08 }, + animationPreset: 'pulse', + }); + } + const m = this.open({ + darken: 0.6, target: 'screen', blockInput: true, + content: { elements }, + }); + const _modal = this; + // _picked: после первого выбора остальные карточки не должны срабатывать, + // пока модал доигрывает fadeOut (иначе onPick зовётся несколько раз). + let _picked = false; + for (let i = 0; i < items.length; i++) { + const id = '_lb_item_' + i; + const it = items[i]; + _guiClickHandlers[id] = [() => { + if (_picked) return; + _picked = true; + for (let j = 0; j < items.length; j++) delete _guiClickHandlers['_lb_item_' + j]; + _modal.close(m); + if (typeof onPick === 'function') { try { onPick(it); } catch (e) {} } + }]; + } + return m; + }, + /** Диалог с NPC построчно. lines: ['Привет', 'Как дела?']. onDone() в конце. */ + dialog(npcName, lines, onDone) { + lines = Array.isArray(lines) ? lines : [String(lines || '')]; + let idx = 0; + const elements = [ + { kind: 'frame', id: '_dlg_bg', x: 50, y: 80, w: 80, h: 25, anchor: 'center', + bgGradient: { stops: ['#1a1a3a','#0a0a1a'], angle: 90 }, + borderColor: '#fff', borderWidth: 2, borderRadius: 12 }, + { kind: 'text', id: '_dlg_name', x: 14, y: 71, w: 22, h: 6, anchor: 'center', + text: String(npcName || 'NPC'), textSize: 20, fontWeight: 900, + textColor: '#ffd700', textStroke: { color: '#000', width: 2 }, + bgColor: 'transparent', bgOpacity: 0 }, + { kind: 'text', id: '_dlg_line', x: 50, y: 80, w: 76, h: 12, anchor: 'center', + text: lines[0], textSize: 22, fontWeight: 600, textColor: '#fff', + textAlign: 'left', bgColor: 'transparent', bgOpacity: 0 }, + { kind: 'button', id: '_dlg_next', x: 88, y: 88, w: 8, h: 6, anchor: 'center', + // На ПОСЛЕДНЕЙ строке кнопка сразу показывает галочку «завершить», + // на остальных — стрелку «дальше». + text: lines.length > 1 ? '▶' : '✓', textSize: 22, fontWeight: 900, + bgColor: '#ffd700', textColor: '#000', borderRadius: 8, + borderColor: '#000', borderWidth: 2, + hover: { scale: 1.1, brightness: 1.2 }, active: { scale: 0.92 }, + animationPreset: 'pulse' }, + ]; + const m = this.open({ + darken: 0.5, target: 'scene', blockInput: true, freezeCamera: true, + content: { elements }, + }); + const _modal = this; + // _done защищает от повторного срабатывания: game.modal.close() доигрывает + // fadeOut асинхронно, кнопка остаётся кликабельной — без guard'а каждый + // лишний клик снова звал onDone (баг «Диалог завершён ×7»). + let _done = false; + _guiClickHandlers['_dlg_next'] = [() => { + if (_done) return; + idx++; + if (idx < lines.length) { + _send('gui.update', { id: '_dlg_line', patch: { text: lines[idx] } }); + // Последняя строка достигнута — превращаем «дальше» в «завершить». + if (idx === lines.length - 1) { + _send('gui.update', { id: '_dlg_next', patch: { text: '✓' } }); + } + } else { + _done = true; + delete _guiClickHandlers['_dlg_next']; // снимаем handler сразу + _modal.close(m); + if (typeof onDone === 'function') { try { onDone(); } catch (e) {} } + } + }]; + return m; + }, + /** Подтверждение Да/Нет. */ + confirmation(title, body, onYes, onNo) { + const elements = [ + { kind: 'frame', id: '_cf_bg', x: 50, y: 50, w: 50, h: 30, anchor: 'center', + bgGradient: { stops: ['#2a2a4a','#0a0a1a'], angle: 135 }, + borderColor: '#fff', borderWidth: 2, borderRadius: 14 }, + { kind: 'text', id: '_cf_title', x: 50, y: 41, w: 44, h: 6, anchor: 'center', + text: String(title || 'Подтверждение'), textSize: 28, fontWeight: 900, + textColor: '#fff', textStroke: { color: '#000', width: 2 }, + bgColor: 'transparent', bgOpacity: 0 }, + { kind: 'text', id: '_cf_body', x: 50, y: 50, w: 44, h: 8, anchor: 'center', + text: String(body || ''), textSize: 16, fontWeight: 500, + textColor: '#cfd0e8', bgColor: 'transparent', bgOpacity: 0 }, + { kind: 'button', id: '_cf_yes', x: 38, y: 60, w: 14, h: 6, anchor: 'center', + text: 'Да', bgGradient: { stops: ['#22ff66','#0a803a'], angle: 90 }, + borderColor: '#000', borderWidth: 2, borderRadius: 10, + textColor: '#fff', textSize: 18, fontWeight: 900, + hover: { scale: 1.08, brightness: 1.2 }, active: { scale: 0.94 } }, + { kind: 'button', id: '_cf_no', x: 62, y: 60, w: 14, h: 6, anchor: 'center', + text: 'Нет', bgGradient: { stops: ['#ff5454','#a00000'], angle: 90 }, + borderColor: '#000', borderWidth: 2, borderRadius: 10, + textColor: '#fff', textSize: 18, fontWeight: 900, + hover: { scale: 1.08, brightness: 1.2 }, active: { scale: 0.94 } }, + ]; + const m = this.open({ + darken: 0.6, target: 'screen', blockInput: true, + content: { elements }, + }); + const _modal = this; + // _answered: одна кнопка снимает обе (Да/Нет) сразу, чтобы пока модал + // доигрывает fadeOut нельзя было нажать вторую и продублировать ответ. + let _answered = false; + const _finish = (cb) => { + if (_answered) return; + _answered = true; + delete _guiClickHandlers['_cf_yes']; + delete _guiClickHandlers['_cf_no']; + _modal.close(m); + if (typeof cb === 'function') { try { cb(); } catch (e) {} } + }; + _guiClickHandlers['_cf_yes'] = [() => _finish(onYes)]; + _guiClickHandlers['_cf_no'] = [() => _finish(onNo)]; + return m; + }, + }, + /** + * Инвентарь игрока (Фаза 4.2) — 5 слотов hot-bar. + * game.inventory.add({ name: 'Зелье', kind: 'item' }) + * game.inventory.has('Зелье') — по имени или modelTypeId + * game.inventory.remove('Зелье') + * game.inventory.list() — массив предметов + * game.inventory.clear() + */ + inventory: { + /** Добавить предмет. item: { name, kind?, modelTypeId?, params? }. */ + add(item) { + if (!item || typeof item !== 'object') return; + _send('inventory.give', { + kind: item.kind || 'item', + modelTypeId: item.modelTypeId || null, + name: item.name || 'Предмет', + params: item.params || {}, + }); + }, + /** Убрать первый предмет по имени или modelTypeId. */ + remove(nameOrModel) { + if (typeof nameOrModel !== 'string') return; + _send('inventory.remove', { name: nameOrModel, modelTypeId: nameOrModel }); + }, + /** True если предмет с таким именем/modelTypeId есть в инвентаре. */ + has(nameOrModel) { + if (typeof nameOrModel !== 'string') return false; + return (_inventory.slots || []).some(s => + s && (s.name === nameOrModel || s.modelTypeId === nameOrModel)); + }, + /** Массив всех предметов инвентаря (без пустых слотов). */ + list() { + return (_inventory.slots || []).filter(Boolean).map(s => ({ ...s })); + }, + /** Активный предмет (выбранный слот hot-bar) или null. */ + active() { + const s = (_inventory.slots || [])[_inventory.activeIndex]; + return s ? { ...s } : null; + }, + /** Очистить весь инвентарь. */ + clear() { + _send('inventory.clear', {}); + }, + }, + /** + * Игроки комнаты (Фаза 4.3 — мультиплеер). + * В одиночной игре (редактор) — только локальный игрок. + * game.players.me() — я { sessionId, name, position, hp, ... } + * game.players.all() — массив всех игроков (включая меня) + * game.players.count() — сколько игроков + */ + players: { + /** Локальный игрок. */ + me() { + return _players.me ? { ..._players.me } : null; + }, + /** Все игроки комнаты (включая меня). */ + all() { + return (_players.list || []).map(p => ({ ...p })); + }, + /** Сколько игроков в комнате. */ + count() { + return (_players.list || []).length; + }, + /** Найти игрока по sessionId или null. */ + get(sessionId) { + const p = (_players.list || []).find(x => x.sessionId === sessionId); + return p ? { ...p } : null; + }, + }, + /** + * Общее состояние комнаты (Фаза 4.3) — данные, видимые всем игрокам. + * В одиночной игре работает как локальное хранилище. + * game.room.set('счёт', 10) + * game.room.get('счёт') → 10 + * game.room.onChange('счёт', (v) => game.ui.showText('Счёт: ' + v)) + */ + room: { + /** Установить значение в общем состоянии комнаты. */ + set(key, value) { + if (typeof key !== 'string' || !key) return; + // Оптимистично обновляем локальное зеркало. + _roomState[key] = value; + _send('room.set', { key, value }); + }, + /** Прочитать значение из общего состояния комнаты. */ + get(key) { + if (typeof key !== 'string') return undefined; + return _roomState[key]; + }, + /** Подписаться на изменение ключа общего состояния. fn(value). */ + onChange(key, fn) { + if (typeof key !== 'string' || typeof fn !== 'function') return; + (_roomChangeHandlers[key] = _roomChangeHandlers[key] || []).push(fn); + }, + }, + /** + * Команды (Фаза 4.4) — для командных игр. + * game.teams.create('Красные', '#ff3333') + * game.teams.create('Синие', '#3366ff') + * game.player.setTeam('Красные') + * game.player.team → 'Красные' + */ + teams: { + /** Создать команду с именем и цветом (#hex). */ + create(name, color) { + if (typeof name !== 'string' || !name) return; + _send('teams.create', { + name, + color: typeof color === 'string' ? color : '#888888', + }); + }, + /** Удалить команду. */ + remove(name) { + if (typeof name !== 'string') return; + _send('teams.remove', { name }); + }, + /** Список всех команд — массив { name, color }. */ + all() { + return _teams.map(t => ({ ...t })); + }, + /** Найти команду по имени или null. */ + get(name) { + const t = _teams.find(x => x.name === name); + return t ? { ...t } : null; + }, + }, + /** + * Связи между объектами (Фаза 5, Constraints). + * weld — склейка: объект B движется вместе с A. + * hinge — петля: вращение вокруг оси (двери, рычаги). + * spring — пружина: упругое колебание (батуты). + * Каждый вызов возвращает объект-связь с методами управления. + */ + constraints: { + /** + * Жёстко склеить объект B с A — B следует за A. + * game.constraints.weld(platformRef, crateRef); + */ + weld(refA, refB) { + if (typeof refA !== 'string' || typeof refB !== 'string') return null; + _constraintRefSeq++; + const localRef = 'constraint:_local_' + _constraintRefSeq; + _send('constraint.create', { kind: 'weld', localRef, refA, refB }); + return { + get ref() { return localRef; }, + remove() { _send('constraint.remove', { ref: localRef }); }, + }; + }, + /** + * Петля: объект вращается вокруг вертикальной оси через pivot. + * opts: { pivotX, pivotZ — точка оси, angle — стартовый угол (°) }. + * Метод setAngle(°) поворачивает объект — для дверей/рычагов. + * const door = game.constraints.hinge(doorRef, { pivotX: 5, pivotZ: 0 }); + * door.setAngle(90); // открыть дверь + */ + hinge(ref, opts) { + if (typeof ref !== 'string') return null; + opts = opts || {}; + _constraintRefSeq++; + const localRef = 'constraint:_local_' + _constraintRefSeq; + _send('constraint.create', { + kind: 'hinge', localRef, ref, + pivotX: Number.isFinite(Number(opts.pivotX)) ? Number(opts.pivotX) : undefined, + pivotZ: Number.isFinite(Number(opts.pivotZ)) ? Number(opts.pivotZ) : undefined, + angle: Number(opts.angle) || 0, + }); + return { + get ref() { return localRef; }, + /** Повернуть к углу (градусы) — объект плавно довернётся. */ + setAngle(deg) { + _send('constraint.hingeAngle', { ref: localRef, deg: Number(deg) || 0 }); + }, + remove() { _send('constraint.remove', { ref: localRef }); }, + }; + }, + /** + * Пружина: объект упруго держится в точке покоя (текущая позиция). + * opts: { stiffness — жёсткость, damping — затухание }. + * Метод push(vx,vy,vz) толкает объект — запускает колебание. + * const trampoline = game.constraints.spring(padRef); + * trampoline.push(0, 12, 0); // подбросить вверх + */ + spring(ref, opts) { + if (typeof ref !== 'string') return null; + opts = opts || {}; + _constraintRefSeq++; + const localRef = 'constraint:_local_' + _constraintRefSeq; + _send('constraint.create', { + kind: 'spring', localRef, ref, + stiffness: Number.isFinite(Number(opts.stiffness)) ? Number(opts.stiffness) : undefined, + damping: Number.isFinite(Number(opts.damping)) ? Number(opts.damping) : undefined, + }); + return { + get ref() { return localRef; }, + /** Толкнуть объект (скорость по осям) — запускает колебание. */ + push(vx, vy, vz) { + _send('constraint.springPush', { + ref: localRef, + vx: Number(vx) || 0, vy: Number(vy) || 0, vz: Number(vz) || 0, + }); + }, + remove() { _send('constraint.remove', { ref: localRef }); }, + }; + }, + }, + /** + * Эффекты-объекты сцены (Фаза 5.2): лучи и следы. + * beam — светящаяся линия между точками (лазеры, мосты, цепи). + * trail — шлейф за движущимся объектом. + */ + fx: { + /** + * Луч между двумя точками. opts: { from, to — {x,y,z} или ref + * объекта (тогда луч следит за ним); color: '#hex', width }. + * game.fx.beam({ from: towerRef, to: {x:0,y:5,z:0}, color: '#ff3344' }); + */ + beam(opts) { + opts = opts || {}; + _fxRefSeq++; + const localRef = 'fx:_local_' + _fxRefSeq; + // Задача 08: расширенные опции (текстура/curved/градиент/billboard). + _send('fx.create', { + kind: 'beam', localRef, + from: _normFxPoint(opts.from), to: _normFxPoint(opts.to), + color: typeof opts.color === 'string' ? opts.color : undefined, + width: Number.isFinite(Number(opts.width)) ? Number(opts.width) : undefined, + texture: opts.texture, customTextureUrl: opts.customTextureUrl, + textureMode: opts.textureMode, textureSpeed: opts.textureSpeed, + textureScale: opts.textureScale, + strokeColor: opts.strokeColor, strokeWidth: opts.strokeWidth, + colorSequence: opts.colorSequence, + transparencySequence: opts.transparencySequence, + widthSequence: opts.widthSequence, + faceMode: opts.faceMode, segments: opts.segments, + curved: opts.curved, curveHeight: opts.curveHeight, + attachOffset: opts.attachOffset, ignoreDepth: opts.ignoreDepth, + }); + return { + get ref() { return localRef; }, + /** Сменить цвет луча. */ + setColor(color) { + _send('fx.beamColor', { ref: localRef, color }); + }, + /** Сменить концы луча ({x,y,z} или ref). */ + setEndpoints(from, to) { + _send('fx.beamEndpoints', { ref: localRef, from, to }); + }, + /** Изменить любые опции луча на лету. */ + update(o) { + _send('fx.beamUpdate', { ref: localRef, opts: o || {} }); + }, + hide() { _send('fx.beamVisible', { ref: localRef, visible: false }); }, + show() { _send('fx.beamVisible', { ref: localRef, visible: true }); }, + remove() { _send('fx.remove', { ref: localRef }); }, + }; + }, + /** + * Стрелка-указатель «иди сюда» (бегущие шевроны + парящий quest-marker + * над целью). Задача 08. + * const arrow = game.fx.pointer({ from: 'player', to: cubeRef, preset: 'guide' }); + * arrow.setTarget(otherRef); arrow.update({ preset: 'quest' }); arrow.remove(); + * preset: 'guide'|'quest'|'danger'|'gift'|'custom'. + * from/to: 'player' | ref-объекта | {x,y,z}. + */ + pointer(opts) { + opts = opts || {}; + _fxRefSeq++; + const localRef = 'fx:_local_' + _fxRefSeq; + _send('fx.createPointer', { + localRef, + from: _normFxPoint(opts.from !== undefined ? opts.from : 'player'), + to: _normFxPoint(opts.to), + preset: opts.preset || 'guide', + color: opts.color, texture: opts.texture, + customTextureUrl: opts.customTextureUrl, + textureSpeed: opts.textureSpeed, width: opts.width, + strokeColor: opts.strokeColor, colorSequence: opts.colorSequence, + curved: opts.curved, curveHeight: opts.curveHeight, + faceMode: opts.faceMode, attachOffset: opts.attachOffset, + }); + return { + get ref() { return localRef; }, + setTarget(to) { _send('fx.pointerTarget', { ref: localRef, to: _normFxPoint(to) }); }, + update(o) { _send('fx.pointerUpdate', { ref: localRef, opts: o || {} }); }, + hide() { _send('fx.beamVisible', { ref: localRef, visible: false }); }, + show() { _send('fx.beamVisible', { ref: localRef, visible: true }); }, + remove() { _send('fx.remove', { ref: localRef }); }, + }; + }, + /** + * Шлейф за объектом. ref — ref-строка объекта. + * opts: { color: '#hex', width, lifetime (сек) }. + * game.fx.trail(ballRef, { color: '#ffcc44', lifetime: 2 }); + */ + trail(ref, opts) { + if (typeof ref !== 'string') return null; + opts = opts || {}; + _fxRefSeq++; + const localRef = 'fx:_local_' + _fxRefSeq; + _send('fx.create', { + kind: 'trail', localRef, ref, + color: typeof opts.color === 'string' ? opts.color : undefined, + width: Number.isFinite(Number(opts.width)) ? Number(opts.width) : undefined, + lifetime: Number.isFinite(Number(opts.lifetime)) ? Number(opts.lifetime) : undefined, + }); + return { + get ref() { return localRef; }, + remove() { _send('fx.remove', { ref: localRef }); }, + }; + }, + }, + /** + * Звуки. Два вида: + * 1. Встроенные пресеты: 'jump', 'pickup', 'win', 'lose', 'click', + * 'hit', 'coin' — game.sound.play('jump'). + * 2. Свои загруженные (Фаза 5.5) — id вида 'sound_N', можно 3D: + * game.sound.play('sound_1', { at: {x,y,z} }) — 3D в точке + * game.sound.play('sound_1', { attach: doorRef }) — 3D у объекта + * game.sound.play('sound_1', { loop: true }) — зациклить + */ + sound: { + /** + * Проиграть звук. id — пресет ('jump'...) или 'sound_N'. + * opts: { volume: 0..1, loop, at: {x,y,z}, attach: ref-строка }. + * Для пользовательского звука возвращает объект с методом stop(). + */ + play(id, opts) { + if (typeof id !== 'string' || !id) return null; + opts = opts || {}; + // Встроенный пресет — старый формат {name}. + if (id.indexOf('sound_') !== 0) { + _send('sound.play', { + name: id, + volume: Number.isFinite(Number(opts.volume)) ? Number(opts.volume) : 1, + pitch: Number.isFinite(Number(opts.pitch)) ? Number(opts.pitch) : 1, + }); + return null; + } + // Пользовательский звук из библиотеки проекта. + _soundRefSeq++; + const localRef = 'sound:_local_' + _soundRefSeq; + _send('sound.play', { + soundId: id, localRef, + volume: Number.isFinite(Number(opts.volume)) ? Number(opts.volume) : 1, + loop: !!opts.loop, + at: (opts.at && Number.isFinite(Number(opts.at.x))) + ? { x: Number(opts.at.x), y: Number(opts.at.y) || 0, z: Number(opts.at.z) || 0 } + : undefined, + attachRef: typeof opts.attach === 'string' ? opts.attach : undefined, + }); + return { + get ref() { return localRef; }, + /** Остановить этот звук. */ + stop() { _send('sound.stop', { ref: localRef }); }, + }; + }, + }, + /** + * Аудио — GD-музыка и SFX. + * game.audio.playSfx('jump') — короткий звук (jump/death/orb_tap/...) + * game.audio.playMusic('epoch_01_main') — фоновая музыка (зацикленная) + * game.audio.stopMusic() + * game.audio.setMuted(true) + */ + audio: { + playSfx(name) { + if (typeof name !== 'string') return; + _send('audio.playSfx', { name }); + }, + playMusic(trackId) { + if (typeof trackId !== 'string') return; + _send('audio.playMusic', { trackId }); + }, + stopMusic() { + _send('audio.stopMusic', {}); + }, + setMuted(muted) { + _send('audio.setMuted', { muted: !!muted }); + }, + }, + /** + * Экономика — алмазы и рейтинг через серверные API. + * Все вызовы асинхронные (с callback), потому что идут через HTTP. + * + * game.economy.reward('level_1_first_pass', function(res) { + * // res = { ok, already_awarded, diamonds, rating, ... } + * }); + * game.economy.dailyCheck(function(res) { ... }); + * game.economy.getBalance(function(res) { + * // res = { diamonds, rating } + * }); + */ + economy: { + reward(achievementId, fn) { + if (typeof achievementId !== 'string') return; + const reqId = 'eco_rwd_' + (++_economyReqSeq); + if (typeof fn === 'function') _economyCallbacks[reqId] = fn; + _send('economy.reward', { reqId, achievementId }); + }, + dailyCheck(fn) { + const reqId = 'eco_daily_' + (++_economyReqSeq); + if (typeof fn === 'function') _economyCallbacks[reqId] = fn; + _send('economy.dailyCheck', { reqId }); + }, + getBalance(fn) { + const reqId = 'eco_bal_' + (++_economyReqSeq); + if (typeof fn === 'function') _economyCallbacks[reqId] = fn; + _send('economy.getBalance', { reqId }); + }, + spend(amount, reason, fn) { + const reqId = 'eco_spend_' + (++_economyReqSeq); + if (typeof fn === 'function') _economyCallbacks[reqId] = fn; + _send('economy.spend', { reqId, amount: Number(amount) || 0, reason: String(reason || '') }); + }, + }, + /** + * Billboard — 3D-таблички с GUI (как BillboardGui в Roblox). + * Создаются через game.scene.spawn('billboard', {x,y,z, template, content}), + * затем настраиваются через game.billboard.set/update. + * + * Пресеты (template): + * - 'shop-item' — иконка + заголовок + sub-строка + кнопка цены + * - 'shop-purchase' — иконка + название + цена (для покупки) + * - 'banner' — крупный текст + * - 'sign' — простой указатель + * + * Пример (4 таблички-апгрейды): + * const refs = ['vis','range','saws','sprink'].map((kind, i) => { + * return game.scene.spawn('billboard', { + * x: -6 + i*4, y: 3, z: 5, + * template: 'shop-item', + * content: { icon: 'hammer', title: 'Апгрейд', sub: '1 > 2', + * price: '$10,000', gradient: ['#ff5a5a','#ff8a3d'] }, + * }); + * }); + * game.billboard.onClick(refs[0], 'buy', () => { + * game.ui.showText('Куплено!'); + * game.billboard.update(refs[0], { sub: '2 > 3', price: '$20,000' }); + * }); + */ + billboard: { + /** + * Полная замена контента таблички. Если пресет тот же — мгновенно + * перерисует. Если template другой — пересоздаст текстуру. + * ref — string-ref из game.scene.spawn() или game.scene.findOne() + * opts — { template?, face?, content?, elements? } + */ + set(ref, opts) { + const refStr = _normRef(ref); + if (!refStr || typeof opts !== 'object' || opts == null) return; + _send('billboard.set', { ref: refStr, ...opts }); + }, + /** + * Частичное обновление таблички. + * Две формы: + * 1) update(ref, patch) + * patch — частичный content: { sub, price, title, icon, gradient } + * Применяется к content пресета (shop-item/banner/sign). + * 2) update(ref, elementId, patch) + * Обновляет конкретный элемент по id (только для template:'card' + * или elements-режима). Например: update(ref, 'buy', { text: '$15,000' }). + * Для пресетов: 'title'|'sub'|'price'|'icon'|'gradient' тоже + * работают как ключи content. + */ + update(ref, secondArg, thirdArg) { + const refStr = _normRef(ref); + if (!refStr) return; + // 3-аргументная форма: update(ref, elementId, patch) + if (typeof secondArg === 'string' && typeof thirdArg === 'object' && thirdArg !== null) { + _send('billboard.update', { + ref: refStr, + elementId: secondArg, + patch: thirdArg, + }); + return; + } + // 2-аргументная форма: update(ref, patch) + if (typeof secondArg === 'object' && secondArg !== null) { + _send('billboard.update', { ref: refStr, patch: secondArg }); + } + }, + /** + * Подписаться на клик по кнопке таблички (shop-item: buttonId='buy'; + * в кастомных elements — id из элемента kind='button'). + * ref — string-ref + * buttonId — id кнопки (по умолчанию 'buy') + * fn — () => void + */ + onClick(ref, buttonId, fn) { + if (typeof fn !== 'function') { + fn = buttonId; + buttonId = 'buy'; + } + // Принудительная нормализация ref в plain-string: Instance-Proxy + // не сериализуется через postMessage (DataCloneError). + const refStr = _normRef(ref); + if (!refStr || typeof fn !== 'function') return; + const bid = String(buttonId || 'buy'); + const key = refStr + ':' + bid; + if (!_billboardClickHandlers[key]) _billboardClickHandlers[key] = []; + _billboardClickHandlers[key].push(fn); + _send('billboard.onClick', { ref: refStr, buttonId: bid }); + }, + }, + /** Окружение: небо, туман, время суток. */ + environment: { + /** Установить цвет неба (clearColor сцены). hex или 'r,g,b'. */ + setSkyColor(color) { + if (typeof color !== 'string') return; + _send('environment.setSkyColor', { color }); + }, + /** Установить туман: {enabled, color, density}. */ + setFog(opts) { + if (typeof opts !== 'object' || !opts) return; + _send('environment.setFog', opts); + }, + /** Установить время суток (часы, 0..24). */ + setTimeOfDay(hours) { + const h = Number(hours); + if (!Number.isFinite(h)) return; + _send('environment.setTimeOfDay', { hours: h }); + }, + }, + /** + * Управление режимами ввода — курсор и камера. + * В режиме 'ui' мышь работает как обычный курсор (как в браузере), + * не вращает камеру. Нужно для меню/инвентарей. + */ + input: { + /** + * Установить cursor-режим: 'ui' = курсор-как-в-браузере, + * 'game' = pointer-lock (мышь крутит камеру). + */ + setCursorMode(mode) { + if (mode !== 'ui' && mode !== 'game') return; + _send('input.setCursorMode', { mode }); + }, + /** + * Подписаться на движение мыши в UI-режиме. + * fn(x, y) — нормализованные координаты [0..1] относительно канваса. + */ + onMouseMove(fn) { + if (typeof fn !== 'function') return; + _mouseMoveHandlers.push(fn); + }, + /** Зажатие ЛКМ в UI-режиме. fn(x, y). */ + onMouseDown(fn) { + if (typeof fn !== 'function') return; + _mouseDownHandlers.push(fn); + }, + /** Отпускание ЛКМ. fn(x, y). */ + onMouseUp(fn) { + if (typeof fn !== 'function') return; + _mouseUpHandlers.push(fn); + }, + }, + /** + * Управление приложением целиком: переходы между страницами и т.д. + */ + app: { + /** Выйти из проекта на страницу ленты Kubikon-игр. */ + exit() { + _send('app.exit', {}); + }, + /** Перейти на произвольный URL (внутри сайта). */ + navigate(url) { + if (typeof url !== 'string' || !url) return; + _send('app.navigate', { url }); + }, + }, + /** + * УНИВЕРСАЛЬНОЕ хранилище сохранений (game saves). + * Любая игра может хранить произвольный JSON-стейт игрока. Под каждую + * игру таблицу создавать не нужно — всё через эти эндпоинты. + * + * namespace — строка типа 'progress', 'stats', 'inventory'. Под каждый + * одна запись на (project, user). Макс 20 namespace. + * data — произвольный объект JSON, до 50KB. + * + * Примеры: + * game.save.set('progress', { level: 3, gold: 250 }); + * game.save.get('progress', fn(data) {...}); + * game.save.merge('progress', { increment: { attempts: 1 } }); + * game.save.leaderboard('progress', 'gold', 'desc', fn(entries) {...}); + */ + save: { + /** Прочитать namespace. fn(data) — data это сохранённый объект или null. */ + get(namespace, fn) { + if (typeof namespace !== 'string' || !namespace) return; + const reqId = 'sg_get_' + (++_saveReqSeq); + if (typeof fn === 'function') _saveCallbacks[reqId] = fn; + _send('save.get', { reqId, namespace }); + }, + /** Прочитать ВСЕ сохранения юзера. fn(allNamespaces) — { ns1: data, ns2: data }. */ + getAll(fn) { + const reqId = 'sg_all_' + (++_saveReqSeq); + if (typeof fn === 'function') _saveCallbacks[reqId] = fn; + _send('save.getAll', { reqId }); + }, + /** Записать (полная замена). data — объект/массив. */ + set(namespace, data) { + if (typeof namespace !== 'string' || !namespace) return; + if (data === undefined || data === null) return; + _send('save.set', { namespace, data }); + }, + /** Слияние с существующим. opts: + * { patch: {...}, increment: { key: delta }, max: { key: value } } + * patch — ключи копируются поверх + * increment — атомарный +=delta (нужно для счётчиков, не теряются + * данные если игрок играет с двух устройств) + * max — новое значение пишется только если оно больше старого */ + merge(namespace, opts) { + if (typeof namespace !== 'string' || !namespace) return; + if (!opts || typeof opts !== 'object') return; + _send('save.merge', { + namespace, + patch: opts.patch || {}, + increment: opts.increment || {}, + max: opts.max || {}, + }); + }, + /** Шорткат: атомарный +1 к счётчику. */ + increment(namespace, key, delta) { + if (typeof namespace !== 'string' || typeof key !== 'string') return; + const d = Number(delta); + const inc = {}; + inc[key] = Number.isFinite(d) ? d : 1; + _send('save.merge', { namespace, patch: {}, increment: inc, max: {} }); + }, + /** Лидерборд по ключу. order='asc' (меньше=лучше) | 'desc' (больше=лучше). + * fn(entries) — массив { rank, user_id, username, value }. */ + leaderboard(namespace, key, order, fn) { + if (typeof namespace !== 'string' || typeof key !== 'string') return; + const reqId = 'sg_lb_' + (++_saveReqSeq); + if (typeof fn === 'function') _saveCallbacks[reqId] = fn; + _send('save.leaderboard', { + reqId, namespace, key, + order: order === 'asc' ? 'asc' : 'desc', + }); + }, + }, + log(...args) { + const parts = args.map(a => { + if (typeof a === 'string') return a; + if (typeof a === 'number' || typeof a === 'boolean') return String(a); + if (a == null) return String(a); + try { return JSON.stringify(a); } catch (e) { return '[object]'; } + }); + _send('log', { level: 'info', text: parts.join(' ') }); + }, + + /** + * Случайное число. + * random() → 0..1 + * random(max) → 0..max + * random(min, max) → min..max + * random(min, max, true) → целое min..max включительно + */ + random(min, max, integer) { + if (min === undefined) return Math.random(); + if (max === undefined) { max = min; min = 0; } + const a = Number(min), b = Number(max); + if (!Number.isFinite(a) || !Number.isFinite(b)) return 0; + if (integer) { + const lo = Math.ceil(Math.min(a, b)); + const hi = Math.floor(Math.max(a, b)); + return Math.floor(Math.random() * (hi - lo + 1)) + lo; + } + return a + Math.random() * (b - a); + }, + + // Форматирование чисел/времени/денег для UI. Портировано из студии + // (задача 11 — игра «Мой завод» использует game.format.money). + format: { + time(seconds, fmt) { + let sec = Math.max(0, Math.floor(Number(seconds) || 0)); + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + const p2 = (n) => String(n).padStart(2, '0'); + if (fmt === 'hh:mm:ss') return p2(h) + ':' + p2(m) + ':' + p2(s); + if (fmt === 'mm:ss') { + const tm = Math.floor(sec / 60); + return p2(tm) + ':' + p2(s); + } + // auto + if (h > 0) return h + 'ч ' + m + 'м'; + if (m > 0) return m + 'м ' + s + 'с'; + return s + 'с'; + }, + number(n, fmt) { + n = Number(n) || 0; + if (fmt === 'percent') return Math.round(n * 100) + '%'; + if (fmt === 'short') { + const abs = Math.abs(n); + if (abs >= 1e9) return (n / 1e9).toFixed(1).replace('.0', '') + 'B'; + if (abs >= 1e6) return (n / 1e6).toFixed(1).replace('.0', '') + 'M'; + if (abs >= 1e3) return (n / 1e3).toFixed(1).replace('.0', '') + 'K'; + return String(Math.round(n)); + } + // comma — пробелы-разделители тысяч (русский стиль), без regex. + const str = String(Math.abs(Math.round(n))); + let out = ''; + for (let i = 0; i < str.length; i++) { + if (i > 0 && (str.length - i) % 3 === 0) out += ' '; + out += str[i]; + } + return (n < 0 ? '-' : '') + out; + }, + money(amount, unit) { + const num = this.number(amount, 'comma'); + const u = (unit === 'rubles' || unit === undefined) + ? this._plural(Math.round(Number(amount) || 0), 'рублик', 'рублика', 'рубликов') + : unit; + return num + ' ' + u; + }, + duration(seconds) { + let sec = Math.max(0, Math.floor(Number(seconds) || 0)); + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + if (h > 0) return h + ' ' + this._plural(h, 'час', 'часа', 'часов'); + if (m > 0) return m + ' ' + this._plural(m, 'минута', 'минуты', 'минут'); + return sec + ' ' + this._plural(sec, 'секунда', 'секунды', 'секунд'); + }, + // Русское склонение числительных (1 рублик / 2 рублика / 5 рубликов). + _plural(n, one, few, many) { + n = Math.abs(n) % 100; + const n1 = n % 10; + if (n > 10 && n < 20) return many; + if (n1 > 1 && n1 < 5) return few; + if (n1 === 1) return one; + return many; + }, + }, + + /** + * Расстояние между двумя точками или объектами. + * Аргументы могут быть {x,y,z} или ref-строкой (для ref берём позицию из sceneIndex). + */ + distance(a, b) { + const pa = _resolveToPos(a); + const pb = _resolveToPos(b); + if (!pa || !pb) return Infinity; + const dx = pa.x - pb.x, dy = pa.y - pb.y, dz = pa.z - pb.z; + return Math.sqrt(dx * dx + dy * dy + dz * dz); + }, + + /** + * Отправить именованное сообщение всем скриптам (включая себя). + * Используется для общения между скриптами в разных sandbox'ах. + * game.broadcast('checkpoint', { num: 2 }); + */ + broadcast(name, data) { + if (typeof name !== 'string' || !name) return; + _send('broadcast', { name, data: data == null ? null : data }); + }, + + /** + * Подключить скрипт-модуль и получить его exports. + * Модуль — другой скрипт проекта; в нём пишут в объект exports: + * // скрипт "math_utils": + * exports.add = (a, b) => a + b; + * // обычный скрипт: + * const m = game.require('math_utils'); + * game.log(m.add(2, 3)); // 5 + * Модуль исполняется один раз, дальше отдаётся из кеша. + */ + require(name) { + if (typeof name !== 'string' || !name) return null; + if (_moduleCache[name]) return _moduleCache[name]; + const code = _moduleCode[name]; + if (code == null) { + _send('log', { level: 'error', text: 'game.require: модуль не найден — ' + name }); + return null; + } + try { + const exportsObj = {}; + // модуль видит game и exports; повторный require внутри модуля тоже работает + const moduleFn = new Function('game', 'exports', '"use strict";\\n' + code); + moduleFn(game, exportsObj); + _moduleCache[name] = exportsObj; + return exportsObj; + } catch (err) { + _send('log', { + level: 'error', + text: 'Ошибка в модуле "' + name + '": ' + (err && err.message ? err.message : err), + }); + return null; + } + }, + + /** + * Подписаться на сообщение. + * game.onMessage('checkpoint', (data) => { ... }); + */ + onMessage(name, fn) { + if (typeof name !== 'string' || !name) return; + if (typeof fn !== 'function') return; + (_messageHandlers[name] = _messageHandlers[name] || []).push(fn); + }, + + /** Зажать значение между min и max. */ + clamp(value, min, max) { + const v = Number(value); + const lo = Number(min), hi = Number(max); + if (!Number.isFinite(v)) return 0; + if (v < lo) return lo; + if (v > hi) return hi; + return v; + }, + + /** Линейная интерполяция: lerp(a, b, 0)=a, lerp(a, b, 1)=b. */ + lerp(a, b, t) { + const na = Number(a), nb = Number(b), nt = Number(t); + return na + (nb - na) * nt; + }, + /** + * game.placement — drag-and-drop размещение объектов (задача 11). + * Фундамент tycoon/farm/simulator: «кликнул предмет → preview за курсором + * → ЛКМ ставит». См. 11_placement_mode.md. + * + * game.placement.start('crate', { + * previewType: 'model:crate', surfaceMode: 'ground', grid: 1, + * cost: 50, currency: 'rubles', targetZone: game.scene.findOne('plot'), + * showArrowFrom: 'player', showZoneOutline: true, chainPlace: true, + * }); + * game.placement.onPlace(({ itemKey, position, rotationY }) => { ... }); + */ + placement: { + /** Войти в режим расстановки. opts — см. 11_placement_mode.md §2.1. */ + start(itemKey, opts) { + if (typeof itemKey !== 'string' || !itemKey) return null; + const o = opts && typeof opts === 'object' ? opts : {}; + // targetZone может прийти как ref-объект findOne — нормализуем в строку. + const out = { itemKey, opts: { ...o } }; + if (o.targetZone) out.opts.targetZone = _normRef(o.targetZone) || o.targetZone; + _send('placement.start', out); + return itemKey; + }, + /** Отменить активный режим (как ПКМ/Esc). */ + cancel() { _send('placement.cancel', {}); }, + /** Поставить на текущей позиции (как ЛКМ). */ + confirm() { _send('placement.confirm', {}); }, + /** Повернуть preview на N градусов (по умолчанию rotationStep). */ + rotate(deg) { _send('placement.rotate', { deg: Number(deg) || undefined }); }, + /** fn({ itemKey, position:{x,y,z}, rotationY }) — объект размещён. */ + onPlace(fn) { if (typeof fn === 'function') _placeOnPlaceHandlers.push(fn); }, + /** fn() — режим отменён игроком. */ + onCancel(fn) { if (typeof fn === 'function') _placeOnCancelHandlers.push(fn); }, + /** fn({ position:{x,y,z}, valid }) — каждый кадр, движение preview. */ + onMove(fn) { if (typeof fn === 'function') _placeOnMoveHandlers.push(fn); }, + }, + + /** + * game.inventoryUi — готовый GUI-кит «слот-инвентарь магазина» (задача 11). + * Нижняя/боковая панель кнопок-слотов с иконкой/ценой/hover. Клик по слоту + * → onSlotClick(item) (обычно автор зовёт game.placement.start внутри). + * Слот серый и некликабельный, если валюты недостаточно (showCurrency + getBalance). + * + * game.inventoryUi.create({ + * items: [{ key:'crate', name:'Базовый ящик', icon:'crate', cost:50, modelType:'model:crate' }], + * position: 'bottom', showCost: true, showCurrency: 'rubles', + * onSlotClick: (item) => game.placement.start(item.key, {...}), + * }); + */ + inventoryUi: { + /** Создать панель слотов. См. 11_placement_mode.md §2.7. */ + create(opts) { + const o = opts && typeof opts === 'object' ? opts : {}; + const items = Array.isArray(o.items) ? o.items : []; + if (typeof o.onSlotClick === 'function') { + // Регистрируем колбэк под индексом — движок пришлёт invUiSlotClick {key}. + _invUiSlotClickHandlers.push(o.onSlotClick); + } + _send('inventoryUi.create', { + items: items.map(it => ({ + key: String(it.key || ''), + name: String(it.name || ''), + icon: it.icon || '', + cost: Number(it.cost) || 0, + modelType: it.modelType || '', + })), + position: o.position || 'bottom', + slotSize: Number(o.slotSize) || 80, + spacing: Number(o.spacing) || 4, + showCost: o.showCost !== false, + showCurrency: o.showCurrency || '', + }); + }, + /** Обновить баланс валюты (для авто-серых слотов). */ + setBalance(currency, amount) { + _send('inventoryUi.setBalance', { currency: String(currency || ''), amount: Number(amount) || 0 }); + }, + /** Скрыть/удалить панель. */ + remove() { _send('inventoryUi.remove', {}); }, + }, + +}; + +/** + * Пересечение луча с AABB (в локальных координатах бокса, центр в 0). + * Возвращает расстояние t до точки входа или null если не пересекает. + * Slab-метод. (ox,oy,oz)=начало луча, (dx,dy,dz)=направление (нормализ.), + * (hw,hh,hd)=полуразмеры бокса. + */ +function _rayAabb(ox, oy, oz, dx, dy, dz, hw, hh, hd) { + let tmin = -Infinity, tmax = Infinity; + const axes = [ + [ox, dx, hw], [oy, dy, hh], [oz, dz, hd], + ]; + for (const [o, d, h] of axes) { + if (Math.abs(d) < 1e-9) { + // луч параллелен слою — мимо если начало вне границ + if (o < -h || o > h) return null; + } else { + let t1 = (-h - o) / d; + let t2 = (h - o) / d; + if (t1 > t2) { const tmp = t1; t1 = t2; t2 = tmp; } + if (t1 > tmin) tmin = t1; + if (t2 < tmax) tmax = t2; + if (tmin > tmax) return null; + } + } + // tmin<0 значит начало внутри бокса — берём 0 + return tmin >= 0 ? tmin : (tmax >= 0 ? 0 : null); +} + +/** Резолв позиции из {x,y,z} или ref-строки. */ +function _resolveToPos(arg) { + if (!arg) return null; + if (typeof arg === 'string') { + for (const b of _sceneIndex.blocks) if (b.ref === arg) return { x: b.x, y: b.y, z: b.z }; + for (const m of _sceneIndex.models) if (m.ref === arg) return { x: m.x, y: m.y, z: m.z }; + for (const p of _sceneIndex.primitives) if (p.ref === arg) return { x: p.x, y: p.y, z: p.z }; + return null; + } + if (typeof arg === 'object' && Number.isFinite(arg.x)) { + return { x: Number(arg.x) || 0, y: Number(arg.y) || 0, z: Number(arg.z) || 0 }; + } + return null; +} + +// === Обработчики сообщений из main === +self.onmessage = (e) => { + const { cmd, payload } = e.data || {}; + if (cmd === 'init') { + // payload: { code, target?, selfPosition?, modules? } + if (payload && payload.target) { + _target = payload.target; + if (payload.selfPosition) _selfPosition = payload.selfPosition; + _selfApi = _buildSelfApi(); + } + // modules: { 'имя': 'код' } — все скрипты проекта, доступные через game.require + if (payload && payload.modules && typeof payload.modules === 'object') { + _moduleCode = payload.modules; + } + // Первичный snapshot сцены — заполняем _sceneIndex ДО исполнения кода, + // чтобы findOne()/find() работали в синхронном теле скрипта на старте + // (иначе obj.onTouch(...) не подписывался — объект ещё «не существовал»). + if (payload && payload.initialScene && typeof payload.initialScene === 'object') { + const s = payload.initialScene; + _sceneIndex = { + blocks: s.blocks || [], + models: s.models || [], + primitives: s.primitives || [], + }; + } + try { + // exports передаём всегда — скрипт может быть и модулем (пишет в + // exports), и обычным скриптом (игнорирует его). Без этого + // скрипт-модуль падает с 'exports is not defined' при прямом запуске. + const exportsObj = {}; + const userFn = new Function('game', 'exports', '"use strict";\\n' + payload.code); + userFn(game, exportsObj); + _send('ready', null); + } catch (err) { + _send('log', { level: 'error', text: 'Ошибка скрипта: ' + (err && err.message ? err.message : err) }); + _send('ready', null); + } + } else if (cmd === 'tick') { + const dt = payload && typeof payload.dt === 'number' ? payload.dt : 0; + if (payload && payload.player) { + const pp = payload.player; + if (pp.position) _playerState.position = pp.position; + if (typeof pp.yaw === 'number') _playerState.yaw = pp.yaw; + if (typeof pp.pitch === 'number') _playerState.pitch = pp.pitch; + if (pp.forward) _playerState.forward = pp.forward; + if (typeof pp.crosshair === 'string') _playerState.crosshair = pp.crosshair; + if (typeof pp.hp === 'number') _playerState.hp = pp.hp; + if (typeof pp.maxHp === 'number') _playerState.maxHp = pp.maxHp; + // Кубикон Dash: направление гравитации (+1 / -1). + if (typeof pp.gravityDir === 'number') _playerState.gravityDir = pp.gravityDir; + if (typeof pp.state === 'string') _playerState.state = pp.state; + if (pp.keys && typeof pp.keys === 'object') _playerState.keys = pp.keys; + } + if (payload && payload.selfPosition) { + _selfPosition = payload.selfPosition; + } + if (payload && Array.isArray(payload.mobs)) { + _mobs = payload.mobs; + } + if (payload && Array.isArray(payload.npcs)) { + _npcs = payload.npcs; + } + if (payload && payload.inventory && typeof payload.inventory === 'object') { + _inventory = payload.inventory; + } + if (payload && payload.players && typeof payload.players === 'object') { + _players = payload.players; + } + if (payload && payload.roomState && typeof payload.roomState === 'object') { + _roomState = payload.roomState; + } + if (payload && Array.isArray(payload.teams)) { + _teams = payload.teams; + } + for (const fn of _tickHandlers) { + _safeCall(fn, dt, 'onTick'); + } + // Таймеры game.after / game.every — копим dt, срабатываем при достижении delay. + // Итерируем по копии: callback может вызвать game.after/cancel и изменить _timers. + if (_timers.length > 0 && dt > 0) { + const due = []; + for (const t of _timers) { + t.elapsed += dt; + if (t.elapsed >= t.delay) due.push(t); + } + for (const t of due) { + if (t.repeat) { + // отнимаем delay (не сбрасываем в 0) — равномерный интервал без дрейфа + t.elapsed -= t.delay; + } else { + const i = _timers.indexOf(t); + if (i >= 0) _timers.splice(i, 1); + } + _safeCall(t.fn, undefined, t.repeat ? 'every' : 'after'); + } + } + } else if (cmd === 'state') { + if (payload && payload.player) { + const pp = payload.player; + if (pp.position) _playerState.position = pp.position; + if (typeof pp.yaw === 'number') _playerState.yaw = pp.yaw; + if (typeof pp.pitch === 'number') _playerState.pitch = pp.pitch; + if (pp.forward) _playerState.forward = pp.forward; + if (typeof pp.crosshair === 'string') _playerState.crosshair = pp.crosshair; + if (typeof pp.hp === 'number') _playerState.hp = pp.hp; + if (typeof pp.maxHp === 'number') _playerState.maxHp = pp.maxHp; + // Кубикон Dash: направление гравитации (+1 / -1). + if (typeof pp.gravityDir === 'number') _playerState.gravityDir = pp.gravityDir; + if (typeof pp.state === 'string') _playerState.state = pp.state; + if (pp.keys && typeof pp.keys === 'object') _playerState.keys = pp.keys; + } + if (payload && payload.selfPosition) { + _selfPosition = payload.selfPosition; + } + if (payload && Array.isArray(payload.mobs)) { + _mobs = payload.mobs; + } + if (payload && Array.isArray(payload.npcs)) { + _npcs = payload.npcs; + } + if (payload && payload.inventory && typeof payload.inventory === 'object') { + _inventory = payload.inventory; + } + if (payload && payload.players && typeof payload.players === 'object') { + _players = payload.players; + } + if (payload && payload.roomState && typeof payload.roomState === 'object') { + _roomState = payload.roomState; + } + if (payload && Array.isArray(payload.teams)) { + _teams = payload.teams; + } + } else if (cmd === 'event') { + // payload: { type, ...data } + const t = payload?.type; + if (t === 'click') { + // self.onClick — только если есть target и target совпал + for (const fn of _selfClickHandlers) _safeCall(fn, payload, 'self.onClick'); + } else if (t === 'touch') { + for (const fn of _selfTouchHandlers) _safeCall(fn, payload, 'self.onTouch'); + } else if (t === 'untouch') { + for (const fn of _selfUntouchHandlers) _safeCall(fn, payload, 'self.onUntouch'); + } else if (t === 'interact') { + for (const fn of _selfInteractHandlers) _safeCall(fn, payload, 'self.onInteract'); + } + } else if (cmd === 'globalEvent') { + // payload: { type, ...data } — глобальные события (всем sandbox'ам) + const t = payload?.type; + if (t === 'click') { + for (const fn of _globalClickHandlers) _safeCall(fn, payload, 'onClick'); + } else if (t === 'mouseMove') { + for (const fn of _mouseMoveHandlers) { + try { fn(payload.x, payload.y); } + catch (err) { + _send('log', { level: 'error', text: 'onMouseMove: ' + (err && err.message ? err.message : err) }); + } + } + } else if (t === 'mouseDown') { + for (const fn of _mouseDownHandlers) { + try { fn(payload.x, payload.y); } + catch (err) { + _send('log', { level: 'error', text: 'onMouseDown: ' + (err && err.message ? err.message : err) }); + } + } + } else if (t === 'mouseUp') { + for (const fn of _mouseUpHandlers) { + try { fn(payload.x, payload.y); } + catch (err) { + _send('log', { level: 'error', text: 'onMouseUp: ' + (err && err.message ? err.message : err) }); + } + } + } else if (t === 'playerTouch') { + for (const fn of _globalTouchHandlers) _safeCall(fn, payload, 'onPlayerTouch'); + } else if (t === 'instTouch' || t === 'instUntouch' || t === 'instClick') { + // Касание/клик произвольного объекта (findOne(x).onTouch/onUntouch/onClick). + const b = _instTouchHandlers.get(payload && payload.ref); + if (b) { + const list = t === 'instTouch' ? b.touch : t === 'instUntouch' ? b.untouch : b.click; + for (const fn of list) _safeCall(fn, payload, 'inst.' + t); + } + } else if (t === 'hpChange') { + for (const fn of _hpChangeHandlers) _safeCall(fn, payload, 'onHpChange'); + } else if (t === 'mobKilled') { + for (const fn of _mobKilledHandlers) _safeCall(fn, payload, 'onMobKilled'); + } else if (t === 'npcDeath') { + // payload: { npcId, position } + const npcId = payload.npcId; + const ev = { id: npcId, position: payload.position }; + // Глобальные подписчики game.onNpcDeath(fn). + for (const fn of _globalNpcDeathHandlers) _safeCall(fn, ev, 'onNpcDeath'); + // Адресные подписки npc.onDeath — по числовому id ИЛИ по + // локальному ref, который при спавне привязали к этому id. + const keys = [String(npcId)]; + for (const [lref, real] of Object.entries(_npcLocalToReal)) { + if (real === npcId) keys.push(lref); + } + for (const key of keys) { + const arr = _npcDeathHandlers[key] || []; + for (const fn of arr) _safeCall(fn, ev, 'npc.onDeath'); + } + } else if (t === 'toolUse') { + // payload: { tool: {kind, modelTypeId, name}, point, target } + const ev = { + tool: payload.tool || null, + point: payload.point || null, + target: payload.target || null, + }; + for (const fn of _toolUseHandlers) _safeCall(fn, ev, 'onToolUse'); + } else if (t === 'cutsceneDone') { + // Катсцена камеры завершилась (Фаза 5.7). + for (const fn of _cutsceneDoneHandlers) _safeCall(fn, undefined, 'onCutsceneDone'); + } else if (t === 'playerJoin') { + // payload: { sessionId, name } + for (const fn of _playerJoinHandlers) _safeCall(fn, payload, 'onPlayerJoin'); + } else if (t === 'playerLeave') { + for (const fn of _playerLeaveHandlers) _safeCall(fn, payload, 'onPlayerLeave'); + } else if (t === 'roomChange') { + // payload: { key, value } — изменилось общее состояние комнаты. + const arr = _roomChangeHandlers[payload.key] || []; + for (const fn of arr) _safeCall(fn, payload.value, 'room.onChange:' + payload.key); + } else if (t === 'mpMessage') { + // payload: { from, name, data } — адресное сообщение. + const arr = _mpMessageHandlers[payload.name] || []; + for (const fn of arr) { + _safeCall(fn, { from: payload.from, data: payload.data }, + 'onMessage:' + payload.name); + } + } else if (t === 'playerDied') { + for (const fn of _playerDiedHandlers) _safeCall(fn, undefined, 'onPlayerDied'); + } else if (t === 'playerJump') { + for (const fn of _playerJumpHandlers) _safeCall(fn, undefined, 'onPlayerJump'); + } else if (t === 'playerLand') { + for (const fn of _playerLandHandlers) _safeCall(fn, undefined, 'onPlayerLand'); + } else if (t === 'keydown') { + const key = String(payload.key || '').toLowerCase(); + const arr = _globalKeyDownHandlers[key] || []; + for (const fn of arr) _safeCall(fn, payload, 'onKey:' + key); + const wild = _globalKeyDownHandlers['*'] || []; + for (const fn of wild) _safeCall(fn, payload, 'onKey(*)'); + } else if (t === 'keyup') { + const key = String(payload.key || '').toLowerCase(); + const arr = _globalKeyUpHandlers[key] || []; + for (const fn of arr) _safeCall(fn, payload, 'onKeyUp:' + key); + const wild = _globalKeyUpHandlers['*'] || []; + for (const fn of wild) _safeCall(fn, payload, 'onKeyUp(*)'); + } else if (t === 'message') { + const name = String(payload.name || ''); + const arr = _messageHandlers[name] || []; + for (const fn of arr) _safeCall(fn, payload.data, 'onMessage:' + name); + } else if (t === 'guiClick') { + const id = String(payload.id || ''); + const localId = payload.localId != null ? String(payload.localId) : null; + // Собираем handlers по id, по локальному ref и по имени элемента — + // скрипт мог подписаться любым из этих ключей. + // _matched защищает от двойного вызова если несколько ключей ведут + // к одному и тому же массиву handlers. + const _matched = new Set(); + for (const key of _guiHandlerKeys(id, localId)) { + const arr = _guiClickHandlers[key]; + if (!arr || _matched.has(arr)) continue; + _matched.add(arr); + for (const fn of arr) _safeCall(fn, { id }, 'gui.onClick:' + key); + } + } else if (t === 'guiSubmit') { + const id = String(payload.id || ''); + const localId = payload.localId != null ? String(payload.localId) : null; + const val = payload.value != null ? String(payload.value) : ''; + const _matched = new Set(); + for (const key of _guiHandlerKeys(id, localId)) { + const arr = _guiSubmitHandlers[key]; + if (!arr || _matched.has(arr)) continue; + _matched.add(arr); + for (const fn of arr) _safeCall(fn, val, 'gui.onSubmit:' + key); + } + } else if (t === 'billboardClick') { + // payload: { ref, button } — клик по кнопке 3D-таблички. + // Ищем handlers и по реальному ref (primitive:NN), и по локальному + // ref если такой есть (на случай если скрипт подписался по + // локальному ref от scene.spawn). + const realRef = String(payload.ref || ''); + const button = String(payload.button || 'buy'); + const tryKeys = [realRef + ':' + button]; + // Если есть локальный ref, ведущий к этому real — тоже попробуем + // (скрипт мог подписаться на ref сразу после game.scene.spawn, + // когда ref был ещё локальным _local_N). + for (const [local, real] of Object.entries(_spawnLocalToReal || {})) { + if (real === realRef) tryKeys.push(local + ':' + button); + } + for (const key of tryKeys) { + const arr = _billboardClickHandlers[key] || []; + for (const fn of arr) _safeCall(fn, { ref: realRef, button }, + 'billboard.onClick:' + key); + } + } else if (t === 'modalOpened') { + // Задача 04: реальный modalId от runtime. worker сразу вернул скрипту + // локальный id (чтобы он мог его сохранить и звать close/update); здесь + // запоминаем маппинг local→real, иначе close(m) уходит с локальным id + // и ModalManager.close его не узнаёт (баг «закрывается только по Esc»). + try { + const mm = (typeof game !== 'undefined') && game.modal; + if (mm && payload && payload.replyId) { + const localId = Number(String(payload.replyId).replace(/^_mopen_/, '')); + if (Number.isFinite(localId) && payload.modalId != null) { + mm._localToReal.set(localId, payload.modalId); + mm._isOpenLocal = true; + } + } + } catch (e) {} + } else if (t === 'modalClosed') { + // Модал закрыт (fadeOut доиграл) — снимаем флаг и зовём onClose-подписчиков. + try { + const mm = (typeof game !== 'undefined') && game.modal; + if (mm) { + mm._isOpenLocal = false; + const cbs = mm._onCloseFns || []; + for (const fn of cbs) _safeCall(fn, payload && payload.id, 'modal.onClose'); + } + } catch (e) {} + } else if (t === 'skinChanged') { + // Задача 07: скин игрока сменился — обновляем зеркало и зовём подписчиков. + const slug = payload && payload.slug; + if (slug) { + _currentSkin = slug; + for (const fn of _skinChangeHandlers) _safeCall(fn, slug, 'player.onSkinChange'); + } + } else if (t === 'skinUnlocked') { + const slug = payload && payload.slug; + if (slug && _unlockedSkins.indexOf(slug) === -1) _unlockedSkins.push(slug); + } else if (t === 'placeConfirm') { + const ev = { itemKey: payload.itemKey, position: payload.position, rotationY: payload.rotationY }; + for (const fn of _placeOnPlaceHandlers) _safeCall(fn, ev, 'placement.onPlace'); + } else if (t === 'placeCancel') { + for (const fn of _placeOnCancelHandlers) _safeCall(fn, undefined, 'placement.onCancel'); + } else if (t === 'placeMove') { + const ev = { position: payload.position, valid: !!payload.valid }; + for (const fn of _placeOnMoveHandlers) _safeCall(fn, ev, 'placement.onMove'); + } else if (t === 'invUiSlotClick') { + const item = payload.item || { key: payload.key }; + for (const fn of _invUiSlotClickHandlers) _safeCall(fn, item, 'inventoryUi.onSlotClick'); + } + } else if (cmd === 'sceneSnapshot') { + // payload: { blocks, models, primitives } — массивы { ref, x, y, z, name?, type? } + if (payload) { + _sceneIndex = { + blocks: payload.blocks || [], + models: payload.models || [], + primitives: payload.primitives || [], + }; + // детект дельт и эмит events для Instance (если кто-то подписан). + try { _detectSnapshotDeltas(); } catch (e) {} + } + } else if (cmd === 'guiSnapshot') { + // payload: массив всех GUI-элементов (для game.gui.find/get/all) + _guiIndex = Array.isArray(payload) ? payload : []; + } else if (cmd === 'skinsSnapshot') { + // Задача 07: payload { all:[{slug,name,kind,category,price}], unlocked:[slug], current } + if (payload && typeof payload === 'object') { + _skinsIndex = Array.isArray(payload.all) ? payload.all : []; + _unlockedSkins = Array.isArray(payload.unlocked) ? payload.unlocked.slice() : []; + _currentSkin = payload.current || _currentSkin; + if (Number.isFinite(payload.coins)) _skinCoins = payload.coins; + } + } else if (cmd === 'dataSnapshot') { + // payload: { ref: { key: value } } — атрибуты всех объектов + _dataIndex = payload && typeof payload === 'object' ? payload : {}; + } else if (cmd === 'terrainHeightmap') { + // payload: { origin:{x,z}, step, cols, rows, heights:[] } + // Карта высот гладкого ландшафта для game.scene.surfaceY. + _terrainHM = payload || null; + } else if (cmd === 'saveResponse') { + // payload: { reqId, result } + const reqId = payload && payload.reqId; + const cb = reqId && _saveCallbacks[reqId]; + if (cb) { + delete _saveCallbacks[reqId]; + try { cb(payload.result); } catch (e) {} + } + } else if (cmd === 'economyResponse') { + // payload: { reqId, result } + const reqId = payload && payload.reqId; + const cb = reqId && _economyCallbacks[reqId]; + if (cb) { + delete _economyCallbacks[reqId]; + try { cb(payload.result); } catch (e) {} + } + } else if (cmd === 'tweenDone') { + // payload: { tweenId } — твин доиграл, зовём onDone + const tid = payload && payload.tweenId; + const cb = tid != null && _tweenCallbacks[tid]; + if (cb) { + delete _tweenCallbacks[tid]; + _safeCall(cb, undefined, 'tween.onDone'); + } + } else if (cmd === 'npcSpawned') { + // payload: { localRef, npcId } — async-спавн NPC завершён. + // Запоминаем маппинг, чтобы npc.onDeath по локальному ref работал. + if (payload && payload.localRef != null) { + _npcLocalToReal[payload.localRef] = payload.npcId; + } + } else if (cmd === 'spawnResolved') { + // payload: { localRef, realRef } — scene.spawn создал объект. + // Запоминаем маппинг для getPosition и т.п. + if (payload && payload.localRef && payload.realRef) { + _spawnLocalToReal[payload.localRef] = payload.realRef; + } + } else if (cmd === 'stop') { + _tickHandlers = []; + _timers = []; + _selfClickHandlers = []; + _selfTouchHandlers = []; + _selfUntouchHandlers = []; + _selfInteractHandlers = []; + _instTouchHandlers.clear(); + _globalKeyDownHandlers = {}; + _globalKeyUpHandlers = {}; + _globalClickHandlers = []; + _globalTouchHandlers = []; + _mouseMoveHandlers = []; + _mouseDownHandlers = []; + _mouseUpHandlers = []; + _mobKilledHandlers = []; + _hpChangeHandlers = []; + _playerDiedHandlers = []; + _playerJumpHandlers = []; + _playerLandHandlers = []; + _messageHandlers = {}; + _guiClickHandlers = {}; + _guiSubmitHandlers = {}; + _npcDeathHandlers = {}; + _globalNpcDeathHandlers = []; + _npcLocalToReal = {}; + _spawnLocalToReal = {}; + _npcs = []; + _toolUseHandlers = []; + _inventory = { slots: [], activeIndex: 0 }; + _players = { me: null, list: [] }; + _roomState = {}; + _playerJoinHandlers = []; + _playerLeaveHandlers = []; + _cutsceneDoneHandlers = []; + _mpMessageHandlers = {}; + _roomChangeHandlers = {}; + _teams = []; + _constraintRefSeq = 0; + _fxRefSeq = 0; + _soundRefSeq = 0; + } +}; + +_send('boot', null); +`; + +/** + * Создаёт URL Worker-кода для new Worker(url). + */ +export function getWorkerSourceUrl() { + const blob = new Blob([SOURCE], { type: 'application/javascript' }); + return URL.createObjectURL(blob); +} diff --git a/src/engine/ShopInventoryUi.js b/src/engine/ShopInventoryUi.js new file mode 100644 index 0000000..0f755ff --- /dev/null +++ b/src/engine/ShopInventoryUi.js @@ -0,0 +1,132 @@ +/** + * ShopInventoryUi — готовый GUI-кит «слот-инвентарь магазина» (задача 11). + * + * Нижняя (или боковая) панель кнопок-слотов с иконкой/названием/ценой и hover. + * Клик по слоту → колбэк onSlotClick(item) — обычно автор вызывает внутри + * game.placement.start(...). Слот серый и некликабельный, если валюты мало + * (показывается, когда заданы showCurrency + текущий баланс через setBalance). + * + * Реализация — лёгкий DOM-оверлей поверх canvas (а не Babylon-GUI): кнопки с + * иконками/hover/disabled делаются на HTML быстрее и доступнее. Крепится к + * родителю canvas, абсолютным позиционированием. + * + * Фича-парность: идентичный модуль в rublox-player/src/engine/. + */ + +// Встроенные inline-SVG иконки слотов (без эмодзи — самописные, см. правила UI). +const SLOT_ICONS = { + crate: '', + plant: '', + oven: '', + coin: '$', + box: '', +}; + +function iconSvg(name) { + return SLOT_ICONS[name] || SLOT_ICONS.box; +} + +export class ShopInventoryUi { + constructor(scene3d) { + this.s = scene3d; + this.root = null; + this.items = []; + this.balance = {}; // currency → amount + this.currency = ''; + this.showCost = true; + this._onSlotClick = null; + this._slotEls = []; + } + + create(opts, onSlotClick) { + this.remove(); + this.items = Array.isArray(opts.items) ? opts.items : []; + this.currency = opts.showCurrency || ''; + this.showCost = opts.showCost !== false; + this._onSlotClick = typeof onSlotClick === 'function' ? onSlotClick : null; + + const parent = (this.s.canvas && this.s.canvas.parentElement) || document.body; + // Контейнер должен быть position:relative чтобы absolute-панель легла поверх. + try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch { /* ignore */ } + + const pos = opts.position || 'bottom'; + const slotSize = Number(opts.slotSize) || 80; + const spacing = Number(opts.spacing) || 4; + + const root = document.createElement('div'); + root.className = 'kbn-shop-inv'; + const sideStyle = { + bottom: `left:50%;bottom:18px;transform:translateX(-50%);flex-direction:row;`, + top: `left:50%;top:18px;transform:translateX(-50%);flex-direction:row;`, + left: `left:18px;top:50%;transform:translateY(-50%);flex-direction:column;`, + right: `right:18px;top:50%;transform:translateY(-50%);flex-direction:column;`, + }[pos] || ''; + root.style.cssText = + `position:absolute;display:flex;gap:${spacing}px;z-index:40;` + + `padding:8px;border-radius:14px;` + + `background:rgba(20,26,38,0.82);backdrop-filter:blur(4px);` + + `box-shadow:0 6px 24px rgba(0,0,0,0.4);` + sideStyle; + + this.items.forEach((it, idx) => { + const slot = document.createElement('button'); + slot.type = 'button'; + slot.dataset.key = it.key; + slot.style.cssText = + `width:${slotSize}px;height:${slotSize}px;border:none;border-radius:11px;` + + `display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;` + + `cursor:pointer;color:#fff;font:600 12px/1.1 system-ui,sans-serif;` + + `background:linear-gradient(180deg,#3a4a66,#26324a);` + + `transition:transform .1s,box-shadow .1s,filter .1s;position:relative;`; + slot.innerHTML = + `${iconSvg(it.icon)}` + + `${it.name || ''}` + + (this.showCost && it.cost + ? `${it.cost}${this.currency ? ' ' + this._curShort() : ''}` + : ''); + slot.onmouseenter = () => { if (!slot.disabled) { slot.style.transform = 'translateY(-3px)'; slot.style.boxShadow = '0 6px 14px rgba(0,0,0,0.45)'; } }; + slot.onmouseleave = () => { slot.style.transform = ''; slot.style.boxShadow = ''; }; + slot.onclick = () => { + if (slot.disabled) return; + if (this._onSlotClick) this._onSlotClick(it); + }; + this._slotEls[idx] = slot; + root.appendChild(slot); + }); + + parent.appendChild(root); + this.root = root; + this._refreshAffordability(); + } + + _curShort() { + const map = { rubles: 'руб', coins: 'мон', diamonds: 'алм' }; + return map[this.currency] || this.currency; + } + + /** Обновить баланс валюты — слоты дороже баланса станут серыми. */ + setBalance(currency, amount) { + if (currency) this.balance[currency] = Number(amount) || 0; + this._refreshAffordability(); + } + + _refreshAffordability() { + if (!this.currency) return; // без валюты все слоты активны + const bal = this.balance[this.currency] != null ? this.balance[this.currency] : Infinity; + this.items.forEach((it, idx) => { + const slot = this._slotEls[idx]; + if (!slot) return; + const afford = (Number(it.cost) || 0) <= bal; + slot.disabled = !afford; + slot.style.filter = afford ? '' : 'grayscale(1) brightness(0.6)'; + slot.style.cursor = afford ? 'pointer' : 'not-allowed'; + slot.style.opacity = afford ? '1' : '0.7'; + }); + } + + remove() { + if (this.root) { try { this.root.remove(); } catch { /* ignore */ } this.root = null; } + this._slotEls = []; + } + + dispose() { this.remove(); this._onSlotClick = null; } +} diff --git a/src/engine/TerrainManager.js b/src/engine/TerrainManager.js index 24ba1f9..2865807 100644 --- a/src/engine/TerrainManager.js +++ b/src/engine/TerrainManager.js @@ -514,6 +514,10 @@ export class TerrainManager { const mat = new StandardMaterial(name, this.scene); // Specular почти выключен — на pixel-art-текстуре любой блик уничтожает стиль. mat.specularColor = new Color3(0, 0, 0); + // 2026-06-02: воксели «просвечивали» (видна задняя грань сквозь переднюю). + // backFaceCulling=false рисует обе стороны, ближняя перекрывает дальнюю + // по depth. Прозрачным (water/glacier) culling оставляем. См. studio. + mat.backFaceCulling = !(def.alpha != null && def.alpha < 1) ? false : true; // Ambient ставим в белый, чтобы hemisphere-light освещал материал // с любой стороны (иначе нижние/тыловые грани выходят серыми, что // особенно заметно на светло-бежевом песке — он становится серым). @@ -543,6 +547,12 @@ export class TerrainManager { mat.diffuseTexture.hasAlpha = true; mat.useAlphaFromDiffuseTexture = true; mat.alpha = def.alpha; + } else { + // RGBA-текстуры (alpha=255) Babylon мог рендерить с alpha-blend → + // воксели просвечивали. Явно OPAQUE для непрозрачных. См. studio. + mat.diffuseTexture.hasAlpha = false; + mat.useAlphaFromDiffuseTexture = false; + mat.transparencyMode = 0; } if (Array.isArray(def.emissive)) { mat.emissiveColor = new Color3(def.emissive[0], def.emissive[1], def.emissive[2]);