studio/src/editor/engine/BabylonScene.js
МИН 7c928462fc
All checks were successful
CI / Lint (pull_request) Successful in 1m8s
CI / Build (pull_request) Successful in 1m56s
CI / Secret scan (pull_request) Successful in 2m31s
CI / PR size check (pull_request) Successful in 6s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
fix(engine): findOne(x).onTouch работает + findOne на старте скрипта
Баг: стрелка-указатель game.fx.pointer не переключалась на следующую цель —
при касании цель не менялась, стрелка не выключалась.

Первопричина (две движковые проблемы):
1. findOne(x).onTouch(...) не существовал: Instance-proxy не имел методов
   касания, движок ловил touch только объектов со скриптом-target/триггеров.
2. Race: скрипт исполняется синхронно в init, а sceneSnapshot приходил позже
   (rAF) → findOne() на старте = null → подписки onTouch молча не вешались.

Фикс:
- Instance-proxy: + onTouch/onUntouch/onClick → шлёт inst.watchTouch{ref}.
  Worker: _instTouchHandlers + маршрут instTouch/instUntouch/instClick по ref.
- GameRuntime: handler inst.watchTouch/watchClick → _watchedTouchRefs;
  routeInstEvent(ref,type); сброс в teardown.
- BabylonScene._detectTouchEvents: блок watched-объектов (AABB по ref, rising/
  falling edge → routeInstEvent), _refToTarget(ref)→{kind,id},
  _touchState.clear() в enterPlayMode.
- Первичный snapshot сцены передаётся прямо в init
  (ScriptSandbox.setInitialScene → worker заполняет _sceneIndex до userFn) →
  findOne работает в синхронном теле скрипта на старте.

Проверено: телепорт игрока по 3 целям игры 333 — стрелка переключается
red-cube→blue-sphere→gold-chest, на финале удаляется.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 08:28:02 +03:00

7680 lines
391 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* BabylonScene — обёртка над Babylon.js Engine + Scene с Roblox-style навигацией.
*
* Управление камерой (как в Roblox Studio):
* - ПКМ + drag : повернуть камеру (yaw/pitch вокруг своей оси)
* - ПКМ + WASD : полёт (вперёд/назад/влево/вправо относительно взгляда)
* - ПКМ + Q/E : вниз/вверх по миру
* - ПКМ + Shift : ускоренный полёт (×2.5)
* - Колесо : zoom (приближение по оси взгляда)
* - Средняя кнопка drag : pan (сдвиг параллельно экрану)
* - F : фокус на (0,0,0) — будет на выбранный объект позже
*
* Используем UniversalCamera + ручной обработчик мыши/клавиш для точной
* имитации Roblox-controls (стандартные attachControl делают не то что нужно).
*
* Этап 1, неделя 1: только сцена, камера и пол с сеткой. Блоки и физика — позже.
*/
import {
Engine,
Scene,
UniversalCamera,
Vector3,
Color3,
Color4,
HemisphericLight,
DirectionalLight,
ShadowGenerator,
CascadedShadowGenerator,
SSAORenderingPipeline,
MeshBuilder,
StandardMaterial,
DynamicTexture,
UtilityLayerRenderer,
TransformNode,
ParticleSystem,
Texture,
Ray,
PointerEventTypes,
Tools as BabylonTools,
} from '@babylonjs/core';
import { BlockManager } from './BlockManager';
import { TerrainManager, VOXEL_SIZE as TERRAIN_VOXEL_SIZE, TERRAIN_MATERIALS as TERRAIN_MATERIAL_DEFS } from './TerrainManager';
// Этап 1 voxel-движка: новые классы chunks-based архитектуры (см.
// RUBLOX_VOXEL_ENGINE_PLAN.md). Пока работают параллельно с legacy
// TerrainManager как shadow-копия — для замеров статистики чанков и
// готовности к Этапу 2 (greedy meshing).
import { VoxelWorld } from './voxel/VoxelWorld';
import { VoxelRenderer } from './voxel/VoxelRenderer';
import { WorldGenerator, DEFAULT_GENERATOR_PARAMS } from './voxel/WorldGenerator';
// Этап 6: deco-слой 0.05м — мелкие воксельные декорации (цветы/грибы/трава).
import { DecoManager } from './DecoManager';
import { GRASS_MODELS_POOL } from './DecoModels';
import { placeVoxelTree, TREE_TYPES } from './VoxelTreeBuilder';
import { UserModelManager, parseUserModelId, USER_MODEL_PREFIX } from './UserModelManager';
import { ModelManager } from './ModelManager';
import { PrimitiveManager } from './PrimitiveManager';
import { BillboardUiManager } from './BillboardUiManager';
import { getPrimitiveType } from './PrimitiveTypes';
import { FolderManager } from './FolderManager';
import { GuiManager } from './GuiManager';
import { ModalManager } from './ModalManager';
import { InventoryManager } from './InventoryManager';
import { WeaponSystem } from './WeaponSystem';
import { ZombieManager } from './ZombieManager';
import { NpcManager } from './NpcManager';
import { ConstraintManager } from './ConstraintManager';
import { BeamManager } from './BeamManager';
import { ZombieSpawnerManager } from './ZombieSpawnerManager';
import { DynamicsManager } from './DynamicsManager';
import { Environment } from './Environment';
import { AudioManager, AMBIENT_PRESETS, MUSIC_PRESETS } from './AudioManager';
import { GameAudioManager } from './GameAudioManager';
import { AssetManager } from './AssetManager';
import { SoundLibrary } from './SoundLibrary';
import { SoundManager } from './SoundManager';
import { GlbLibrary } from './GlbLibrary';
import { GdLevelManager } from './GdLevelManager';
import { GdSkybox } from './GdSkybox';
import { GdGroundSkin } from './GdGroundSkin';
import { GdSpikes } from './GdSpikes';
import { GdStartArch } from './GdStartArch';
import { GdPortalArch } from './GdPortalArch';
import { GdDiamond } from './GdDiamond';
import { GdPlayerModeSkin } from './GdPlayerModeSkin';
import { GdFinish } from './GdFinish';
import { GdForest } from './GdForest';
import { GdPlayerCube } from './GdPlayerCube';
import { GdPlayerTrail } from './GdPlayerTrail';
import { GdPostFx } from './GdPostFx';
import { PhysicsAABB } from './PhysicsAABB';
import { PlayerController } from './PlayerController';
import { SelectionManager } from './SelectionManager';
import { GizmoController } from './GizmoController';
import { HistoryManager } from './HistoryManager';
import { GameRuntime } from './GameRuntime';
import { attachConsoleHook, devlogReset } from './devlog';
import { TerrainMesh, CHUNK_SIZE as TERRAIN_MESH_CHUNK } from './terrain/TerrainMesh';
import { VoxelGrid } from './terrain/VoxelGrid';
import { RobloxTerrain, CHUNK_SIZE as ROBLOX_CHUNK_SIZE } from './robloxterrain/RobloxTerrain';
import { DensityGrid as RobloxDensityGrid, CELL_SIZE as ROBLOX_CELL_SIZE } from './robloxterrain/DensityGrid';
import { SmoothDecoManager } from './robloxterrain/SmoothDecoManager';
export class BabylonScene {
/**
* @param {HTMLCanvasElement} canvas — DOM-элемент <canvas> для рендера
*/
constructor(canvas) {
// DevLog: на localhost подключаем перехват console.* для записи в файл
// на твоей машине (c:\...\dev-tools\devlog.txt). Это даёт Claude
// возможность читать свежие логи без копипасты вручную.
try {
devlogReset();
attachConsoleHook();
} catch (e) {}
this.canvas = canvas;
this.engine = null;
this.scene = null;
this.camera = null;
// Состояние ввода. Храним КОДЫ клавиш (e.code), не key — чтобы
// работало на русской раскладке: KeyW не зависит от языка ввода.
this._codes = new Set();
this._shiftDown = false;
this._isRotating = false; // ПКМ зажата → крутим камеру
this._isPanning = false; // СКМ зажата → pan
this._lastMouseX = 0;
this._lastMouseY = 0;
// Параметры
this.MOVE_SPEED = 12; // юнитов/секунду при WASD
this.SHIFT_MULTIPLIER = 2.5;
this.ROTATE_SENSITIVITY = 0.0035; // радиан/пиксель
this.ZOOM_SPEED = 1.5;
this.PAN_SENSITIVITY = 0.025;
// Состояние редактора блоков
this.blockManager = null;
this.modelManager = null;
this.primitiveManager = null;
this.folderManager = null;
this.guiManager = null; // 2D-UI слой (Frame/Text/Button/Image)
this.inventory = null; // инвентарь игрока (9 слотов hot-bar)
this.weapons = null; // система оружия (создаётся при enterPlayMode)
this.zombieManager = null; // AI зомби (создаётся при enterPlayMode)
this.npcManager = null; // управляемые скриптом NPC (Фаза 4.1)
this.constraintManager = null; // связи объектов (Фаза 5, Constraints)
this.beamManager = null; // лучи и следы (Фаза 5.2)
this.spawnerManager = null; // спавнеры зомби
this.environment = null;
this.audioManager = null;
this.assetManager = null; // библиотека пользовательских картинок
this.soundLibrary = null; // библиотека пользовательских звуков (Фаза 5.5)
this.soundManager = null; // 3D-воспроизведение звука (Play-only)
this.glbLibrary = null; // импортированные .glb-модели (Фаза 5.8)
this.selection = null; // SelectionManager
// Тач-режим (мобилки/планшеты) — выставляется снаружи через
// setTouchMode() ДО enterPlayMode. Влияет на PlayerController.
this._touchMode = false;
this._activeTool = 'block'; // 'select' | 'block' | 'model' | 'primitive' | 'erase'
this._activeBlockType = 'grass';
this._activeModelType = null;
this._activePrimitiveType = 'cube';
this._ghostMesh = null;
this._ghostRotationY = 0; // угол поворота ghost-модели (R = +90°)
this._gizmo = null;
this._gizmoLayer = null;
this._gizmoDragging = false; // флаг что идёт drag гизмо
this._isDragPlacing = false; // флаг drag-постановки/удаления блоков
this._isTerrainBrushing = false; // флаг drag-кисти террейна
this._terrainDragLockY = null; // Y-фикс для скульпт/выровнять
this._lastPlacedKey = null; // последняя клетка чтобы не ставить дважды
this._dragLockAxis = null; // 'y' | 'x' | 'z' — плоскость зафиксированная первым блоком
this._dragLockValue = 0; // значение по фиксированной оси
// Точка спавна игрока в режиме Play (обновляется setSpawnPoint)
this._spawnPoint = { x: 0, y: 5, z: 0 };
// Модель персонажа для режима Play.
// Дефолт — R15-скин bacon-hair (классический Roblox-вид).
// 'skin_*' грузится из characters/<id>/body.glb (R15-скелет),
// 'character-*' — старые Kenney-модели.
this._playerModelType = 'skin_bacon-hair';
// Размер пола: пол идёт от -worldHalf до +worldHalf по X и Z.
// По умолчанию 80×80 (worldHalf = 40). Можно менять через setWorldSize().
this._worldHalf = 40;
// Видимость пола (можно «удалить» — пол исчезнет визуально и из физики)
this._floorEnabled = true;
// Множитель силы прыжка (1 = базовый, 1.5 = в 1.5 раза выше)
this._jumpPowerMul = 1;
// Прицел в Play: 'none' | 'dot' | 'cross' | 'circle'. По умолчанию выключен.
this._crosshair = 'none';
// Скрипты пользователя (массив { id, code, target? }).
// На этапе 2.1 — только один глобальный «scene script», без UI редактирования.
// Хранится в проекте через serialize/loadFromState.
this._scripts = [];
this.gameRuntime = null; // GameRuntime создаётся при enterPlayMode
// Режим Play
this.player = null; // PlayerController когда играем
this.physics = null; // PhysicsAABB
this._editorCameraSnapshot = null; // запоминаем позицию редактор-камеры
this._isPlaying = false;
// Drag-detection: чтобы не ставить блок при rotate (mouseup без movement
// = клик; с movement = drag).
this._mouseDownTime = 0;
this._mouseDownX = 0;
this._mouseDownY = 0;
this._mouseDownButton = -1;
// Слушатели — храним чтобы корректно отписаться
this._listeners = [];
this._resizeHandler = null;
}
init() {
// На тач-устройствах сразу отключаем anti-aliasing — это даёт
// заметный буст FPS на мобилах. Anti-aliasing полезен только на
// больших мониторах с низким DPR.
const isTouchDevice = (typeof window !== 'undefined') && (
'ontouchstart' in window || (navigator.maxTouchPoints || 0) > 0
);
const isSmallScreen = (typeof window !== 'undefined')
&& window.innerWidth <= 1024;
const useAA = !(isTouchDevice && isSmallScreen);
// MOBILE-OPT (этап 1): флаг для всех мобильных оптимизаций
// Можно принудительно отключить через ?desktop=1 в URL (для отладки).
const forceDesktop = (typeof window !== 'undefined')
&& new URLSearchParams(window.location.search).has('desktop');
this._isMobileMode = (isTouchDevice && isSmallScreen) && !forceDesktop;
this.engine = new Engine(this.canvas, useAA, {
preserveDrawingBuffer: true,
stencil: true,
// Parallel shader compile — критично для устранения фризов при
// повороте камеры. Когда новый material попадает во frustum,
// Babylon без этого синхронно компилит shader и блокирует UI.
// С параллельным compile рендер использует fallback shader и
// переключается на оптимизированный когда тот готов.
useHighPrecisionFloats: false,
powerPreference: 'high-performance',
}, true);
// MOBILE-OPT (этап 1.5): hardware scaling ОТКЛЮЧЁН.
// Логи показали что узкое место — CPU (draw calls растут до 60k),
// а не GPU fillrate. Скейлинг ухудшал картинку и не помогал FPS.
// PERF-METRICS: счётчики для perf-overlay. Накопительно за окно
// сэмплинга 5сек, потом overlay читает и сбрасывает.
this._perfMetrics = {
render_ms_sum: 0, render_count: 0,
physics_ms_sum: 0, physics_count: 0,
script_ms_sum: 0, script_count: 0,
// Замер idle-времени между концом prev-render и началом next-render.
// Если idle ≈ frame_ms - render_ms — значит мы GPU-bound (JS-поток
// ждёт GPU/V-Sync). Если idle мал — CPU-bound (что-то ещё в JS ест).
idle_ms_sum: 0, idle_count: 0,
_lastRenderEnd: 0,
};
this.scene = new Scene(this.engine);
this.scene.clearColor = new Color4(0.5, 0.7, 0.9, 1.0);
// ambient: материалы TerrainManager ставят mat.ambientColor=(1,1,1),
// но без scene.ambientColor != 0 это умножается на 0 и боковые грани
// вокселей остаются чёрными при тёмных пресетах (sunset/night).
this.scene.ambientColor = new Color3(0.3, 0.3, 0.3);
// Глобальный хендл для отладки из консоли: window.__BS — это инстанс
// BabylonScene; window.__SC — Babylon scene; window.__ENG — engine.
// window.__BJS — набор Babylon-классов для dev-инструментов
// (@babylonjs/core модульный, window.BABYLON не существует) —
// им пользуется съёмщик hero-кадров dev-tools/wiki-shots/shoot-hero.js.
if (typeof window !== 'undefined') {
window.__BS = this;
window.__SC = this.scene;
window.__ENG = this.engine;
window.__BJS = { UniversalCamera, Vector3, Tools: BabylonTools };
}
// ВАЖНО: blockMaterialDirtyMechanism НЕ включаем здесь.
// Когда true — ставим свойства материала (emissiveColor/disableLighting/
// alpha) у новых мешей (трейсеры выстрелов, debris при смерти,
// муззл-флэш, импакт), но шейдер пересчитывается с дефолтами и эти
// свойства не применяются. Эффект: трейсер/дебрис создаются, но
// НЕ ВИДНЫ. Включать только локально вокруг массовых операций
// (если когда-то появится нужда), сразу выключая обратно.
// Skip pointer-move picking — не делаем raycast от мыши на каждый
// mousemove. Игроку важны клик и hover-через-canvas, а не каждый move.
this.scene.skipPointerMovePicking = true;
// Параллельная компиляция шейдеров — фоновая компиляция новых
// материалов без блокировки рендера (если поддержано WebGL2).
if (this.engine.getCaps?.()?.parallelShaderCompile !== undefined) {
try {
// Babylon 6+ — это просто флаг capability, выставляется
// автоматически при поддержке. Логируем для отладки.
// eslint-disable-next-line no-console
console.log('[BabylonScene] parallel shader compile:',
!!this.engine.getCaps().parallelShaderCompile);
} catch (e) {}
}
// Возвращаем detachControl — наши mousedown-listeners на canvas с
// capture=true должны работать без вмешательства Babylon-pointerHandler.
// Гизмо запустим вручную через прямые pointerdown/move/up на utility-сцене.
this.scene.detachControl();
this._createCamera();
this._createLights();
this._createGroundGrid();
this._createGhostBlock();
this._createSpawnMarker();
this._setupInputControls();
// Менеджеры объектов
this.blockManager = new BlockManager(this.scene);
// При создании нового proto-меша блока — сразу регистрируем его
// как shadow caster (если генератор уже создан).
this.blockManager.setOnProtoCreated((proto) => {
this.addShadowCaster(proto);
});
// Менеджер декораций — Этап 6 voxel-движка.
// Мини-воксели 0.05м для цветов/грибов/травы. Без коллизий.
this.decoManager = new DecoManager(this.scene);
this.decoManager.setOnChange(() => {
if (this._onSceneChange) this._onSceneChange();
});
// Менеджер ландшафта — отдельный voxel-слой 1×1×1, рисуемый кистями.
// Использует thin-instances per материал, как BlockManager.
this.terrainManager = new TerrainManager(this.scene);
// ОПТИМИЗАЦИЯ: НЕ регистрируем terrain как shadow caster. Большая
// карта с 150K voxel'ов в shadow renderList даёт +50-100% нагрузки
// на GPU. Тени от деревьев на земле выглядят не критично, а receiveShadows
// оставлен — тени от других объектов (моделей) показываются.
// this.terrainManager.setOnProtoCreated((proto) => {
// this.addShadowCaster(proto);
// });
this.terrainManager.setOnChange(() => {
// Пометить сцену как изменённую — автосохранение подхватит.
// Имя коллбэка — _onSceneChange (то же что у blockManager/
// modelManager/primitiveManager). Раньше тут было _onChange —
// несуществующее поле, из-за чего террейн не сохранялся
// автоматически. Только ручная кнопка «Сохранить» дёргает
// serialize() напрямую и попадала в БД.
if (this._onSceneChange) this._onSceneChange();
});
// === Этап 1 voxel-движка: shadow-копия террейна в новой архитектуре ===
// Параллельно с TerrainManager работает VoxelWorld с теми же voxel'ами,
// но в формате chunks 32×32×32. Пока БЕЗ рендера (флаг useVoxelWorld=
// false) — только структура данных для замера chunk-статистики и
// подготовки к Этапу 2.
// window.__voxelWorldStats() — выведет в консоль текущую статистику.
// window.__voxelWorldRender(true/false) — переключит рендер на новый
// (когда будет готов greedy). Сейчас рендерит дублирующиеся mesh'и
// поверх старых — для визуальной валидации.
this.voxelWorld = new VoxelWorld();
this.voxelWorld.setOnChange(() => {
// Авто-rebuild dirty чанков при изменении (только если рендер включён)
if (this._voxelRenderEnabled && this.voxelRenderer) {
this.voxelRenderer.rebuildDirty();
}
});
this.voxelRenderer = new VoxelRenderer(this.voxelWorld, this.scene);
this.voxelRenderer.setOnMeshCreated((m) => this.addShadowCaster(m));
/** Включить/выключить рендер VoxelWorld. По умолчанию false — только
* loadFromArray в VoxelWorld для статистики, без отображения. */
this._voxelRenderEnabled = false;
/** Этап 4 streaming: рендерить только чанки в радиусе от камеры.
* false по умолчанию — рендерим все чанки (для маленьких карт).
* Включается через window.__voxelWorldStreaming(true, 64). */
this._voxelStreamingEnabled = false;
this._voxelStreamingRadius = 64; // метров
this._voxelStreamingLastUpdate = 0;
this._voxelStreamingInterval = 250; // мс между проверками
if (typeof window !== 'undefined') {
window.__voxelWorldStats = () => {
const s = this.voxelWorld.stats();
console.log('[VoxelWorld stats]', s);
return s;
};
// Диагностика FPS bottleneck'ов на больших картах.
// Запускать в консоли когда лагает: window.__voxelPerfReport()
// Debug-команды для диагностики FPS-проблем.
// Запускать в консоли — увидим что реально жрёт CPU/GPU.
window.__toggleShadows = (on) => {
this.setShadowQuality(on === false ? 'off' : 'soft');
console.log('[Debug] shadows:', on === false ? 'OFF' : 'ON');
};
window.__togglePostProcess = (on) => {
if (this.scene && this.scene.postProcessRenderPipelineManager) {
// Babylon не умеет тривиально выключать pipeline, поэтому
// просто отключаем все pipelines
const enabled = on !== false;
if (this._postProcessPipelines) {
for (const p of this._postProcessPipelines) {
try { p.setEnabled(enabled); } catch (e) {}
}
}
}
console.log('[Debug] post-process:', on === false ? 'OFF' : 'ON');
};
window.__toggleSceneOptim = (on) => {
// Глобальные оптимизации Babylon
const scn = this.scene;
if (on !== false) {
scn.freezeActiveMeshes();
scn.skipFrustumClipping = true;
scn.blockfreeActiveMeshesAndRenderingGroups = true;
console.log('[Debug] scene optim: freezeActiveMeshes + skipFrustumClipping ON');
} else {
scn.unfreezeActiveMeshes();
scn.skipFrustumClipping = false;
scn.blockfreeActiveMeshesAndRenderingGroups = false;
console.log('[Debug] scene optim: OFF');
}
};
window.__voxelPerfReport = () => {
const tm = this.terrainManager;
if (!tm) return console.warn('no terrainManager');
const scn = this.scene;
const eng = scn.getEngine();
const totalMeshes = scn.meshes.length;
let activeMeshes = 0;
let activeRegionMeshes = 0;
let activeDecoMeshes = 0;
for (const m of scn.meshes) {
if (m.isEnabled() && m.material) activeMeshes++;
}
if (tm._regionMeshes) {
for (const m of tm._regionMeshes.values()) {
if (m.isEnabled()) activeRegionMeshes++;
}
}
if (this.decoManager?._chunkMeshes) {
for (const colorMap of this.decoManager._chunkMeshes.values()) {
for (const m of colorMap.values()) {
if (m.isEnabled()) activeDecoMeshes++;
}
}
}
// FPS: Babylon engine.getFps() даёт усреднённый, instantaneous
// (1000/getDeltaTime) скачет хаотично из-за GC.
const stableFps = eng.getFps?.() ?? (1000 / eng.getDeltaTime());
const instFps = 1000 / eng.getDeltaTime();
// Подсчёт активных треугольников и draw calls.
// Babylon хранит sceneInstrumentation, но он opt-in.
// Считаем вручную из активных мешей.
let activeTriangles = 0;
let activeVertices = 0;
let activeDrawCalls = 0;
if (this.scene && this.scene.meshes) {
for (const m of this.scene.meshes) {
if (!m.isEnabled() || !m.material) continue;
// Frustum cull skip
const idxCount = m.getTotalIndices?.() ?? 0;
if (idxCount === 0) continue;
// thin-instances умножают
const instCount = m.thinInstanceCount > 0 ? m.thinInstanceCount : 1;
activeTriangles += (idxCount / 3) * instCount;
activeVertices += (m.getTotalVertices?.() ?? 0) * instCount;
// 1 draw call на меш (multimat = +submeshes)
const subMeshes = m.subMeshes ? m.subMeshes.length : 1;
activeDrawCalls += subMeshes;
}
}
// Frame time: целевые значения
// 60 FPS = 16.6мс/кадр
// 30 FPS = 33.3мс/кадр
// 23 FPS = 43.5мс/кадр ← наша проблема
const frameMs = eng.getDeltaTime();
// Visibility — Chrome даёт throttle до 20 FPS если таб неактивен
const docHidden = typeof document !== 'undefined' && document.hidden;
const winFocused = typeof document !== 'undefined' && document.hasFocus?.();
// PERF-DIAG: где теряется время?
// render_ms — сколько занимает scene.render() (GPU + babylon)
// idle_ms — промежуток между концом render и началом
// следующего кадра (если велик — GPU-bound
// ИЛИ браузер throttle; если мал, а frame_ms
// большой — узкое место в нашем JS до render).
const pm = this._perfMetrics;
const renderMsAvg = pm && pm.render_count
? (pm.render_ms_sum / pm.render_count) : 0;
const idleMsAvg = pm && pm.idle_count
? (pm.idle_ms_sum / pm.idle_count) : 0;
// Сбрасываем накопители — следующий отчёт за свежий период.
if (pm) {
pm.render_ms_sum = 0; pm.render_count = 0;
pm.idle_ms_sum = 0; pm.idle_count = 0;
}
console.log('[PerfReport]', {
fps_stable: stableFps.toFixed(1),
fps_instant: instFps.toFixed(1),
frame_ms: frameMs.toFixed(1),
render_ms: renderMsAvg.toFixed(1),
idle_ms: idleMsAvg.toFixed(1),
isPlaying: this._isPlaying,
triangles_K: (activeTriangles / 1000).toFixed(0) + 'K',
drawCalls: activeDrawCalls,
tab_hidden: docHidden,
win_focused: winFocused,
voxelCount: tm.voxels?.size ?? 0,
sceneMeshes: totalMeshes,
activeMeshes,
regionMeshes: tm._regionMeshes?.size ?? 0,
activeRegionMeshes,
decoMeshes: this.decoManager?._chunkMeshes ? this._decoMeshCount() : 0,
activeDecoMeshes,
streamingRadius: this._terrainStreamingRadius,
// Новый TerrainMesh (Roblox-style, voxel)
tmesh_chunks: this._terrainMesh?.chunks?.size ?? 0,
tmesh_pending: this._terrainMesh?._pendingChunks?.size ?? 0,
tmesh_tris: this._terrainMesh ? this._terrainMesh.getActiveTriangles() : 0,
// Roblox Smooth Terrain
rt_chunks: this._robloxTerrain?.chunks?.size ?? 0,
rt_pending: this._robloxTerrain?._pendingChunks?.size ?? 0,
rt_tris: this._robloxTerrain ? this._robloxTerrain.getStats().triangles : 0,
});
};
this._decoMeshCount = () => {
let n = 0;
if (!this.decoManager?._chunkMeshes) return 0;
for (const m of this.decoManager._chunkMeshes.values()) n += m.size;
return n;
};
// Автомониторинг FPS — каждые 2 сек пишет PerfReport в devlog.
// Активируется автоматически на localhost. На прод не работает.
window.__perfMonitorStart = (interval = 2000) => {
if (window.__perfMonitorTimer) {
clearInterval(window.__perfMonitorTimer);
}
window.__perfMonitorTimer = setInterval(() => {
try { window.__voxelPerfReport?.(); } catch (e) {}
}, interval);
console.log(`[PerfMonitor] started, interval=${interval}ms`);
};
window.__perfMonitorStop = () => {
if (window.__perfMonitorTimer) {
clearInterval(window.__perfMonitorTimer);
window.__perfMonitorTimer = null;
console.log('[PerfMonitor] stopped');
}
};
// Автостарт мониторинга на localhost — Claude читает devlog.txt
if (typeof window !== 'undefined'
&& (window.location.hostname === 'localhost'
|| window.location.hostname === '127.0.0.1')) {
setTimeout(() => { try { window.__perfMonitorStart?.(2000); } catch (e) {} }, 1000);
}
// === Тест нового TerrainMesh (Roblox/Minecraft-style) ===
//
// Создаёт VoxelGrid и заполняет его holmistym ландшафтом из
// sin-волн. Рендерится через Greedy Meshing.
// Использование в DevTools:
// __terrainTest(64) — небольшая карта 64×16×64м
// __terrainTest(150) — большая 150×24×150м
// __terrainTest(250) — целевая 250×32×250м
window.__terrainTest = (sizeMeters = 64) => {
if (!this._terrainMesh) {
this._terrainMesh = new TerrainMesh(this.scene);
}
const tm = this._terrainMesh;
// Удалим старый legacy terrain — он перекрывает картинку
try {
if (this.terrainManager) this.terrainManager.clear();
if (this.decoManager) this.decoManager.clear();
} catch (e) {}
tm.disposeAll();
const t0 = performance.now();
const sx = sizeMeters, sz = sizeMeters;
const sy = 32;
const grid = new VoxelGrid({
origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 },
size: { x: sx, y: sy, z: sz },
});
// Заполняем heightmap-картой: y = базовый + sin(x)*cos(z)
for (let z = 0; z < sz; z++) {
for (let x = 0; x < sx; x++) {
const fx = (x - sx / 2) / sx;
const fz = (z - sz / 2) / sz;
const h = Math.floor(
6 + Math.sin(fx * Math.PI * 3) * 4
+ Math.cos(fz * Math.PI * 4) * 3
+ Math.sin((fx + fz) * Math.PI * 6) * 2,
);
for (let y = 0; y < h && y < sy; y++) {
let mat;
if (y === h - 1) mat = 'grass';
else if (y >= h - 3) mat = 'dirt';
else mat = 'rock';
grid.set(x, y, z, mat);
}
}
}
const tFill = performance.now() - t0;
const solid = grid.countSolid();
console.log(`[TerrainTest] filled grid ${sx}×${sy}×${sz} (${solid} solid voxels) in ${tFill.toFixed(0)}ms`);
tm.loadFromGrid(grid);
// Сразу материализуем ВСЕ chunks (для теста, не lazy)
const t1 = performance.now();
const camX = this.camera?.position.x || 0;
const camZ = this.camera?.position.z || 0;
const r = tm.updateStreaming(camX, camZ, 9999, { maxBuild: 9999 });
const tBuild = performance.now() - t1;
const tris = tm.getActiveTriangles();
console.log(`[TerrainTest] built ${r.built} chunks in ${tBuild.toFixed(0)}ms — ${tris} triangles total`);
console.log(`[TerrainTest] open DevTools → __voxelPerfReport() через 2 сек → должно быть 60+ FPS`);
};
// Удалить тестовый terrain mesh
window.__terrainTestClear = () => {
if (this._terrainMesh) {
this._terrainMesh.disposeAll();
console.log('[TerrainTest] cleared');
}
};
// ============================================================
// Roblox-style Smooth Terrain test
// ============================================================
//
// Использование в DevTools:
// __robloxTest(50) — карта 50×16×50 ячеек = 200×64×200 м
// __robloxTest(125) — 500×64×500 м (огромная, ОК для smooth)
//
// Создаёт holmistyy ландшафт через density-функцию и рендерит
// через Surface Nets. Проверка что архитектура работает.
window.__robloxTest = async (gridSize = 50, userParams = null) => {
if (!this._robloxTerrain) {
this._robloxTerrain = new RobloxTerrain(this.scene);
// Подключить к физике — иначе игрок проваливается в smooth terrain
if (this.physics?.setRobloxTerrain) {
this.physics.setRobloxTerrain(this._robloxTerrain);
}
}
const rt = this._robloxTerrain;
try {
if (this.terrainManager) this.terrainManager.clear();
if (this.decoManager) this.decoManager.clear();
if (this.voxelWorld) {
const layer = this.voxelWorld.getLayer?.('terrain');
if (layer && layer.clear) layer.clear();
}
this._terrainStreamingEnabled = false;
} catch (e) {}
rt.disposeAll();
const t0 = performance.now();
const sx = gridSize, sz = gridSize;
const sy = 24; // высота карты в cells: 24 × 4м = 96м (для гор)
const grid = new RobloxDensityGrid({
origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 },
size: { x: sx, y: sy, z: sz },
});
// === Используем тот же WorldGenerator что и voxel-генератор ===
// 1 smooth-cell = 4м = 16 voxel-units.
// sampleHeight возвращает высоту в voxel-units (0.25м).
// sampleBiome → объект {topMaterial,softMaterial,hardMaterial,...}.
//
// userParams приходит из UI (TerrainGenPanel buildParams).
// Если null — берём дефолтные.
const params = userParams
? JSON.parse(JSON.stringify(userParams))
: JSON.parse(JSON.stringify(DEFAULT_GENERATOR_PARAMS));
console.log(`[RobloxTest] params: amp=${params.heightmap.amplitude}, scale=${params.heightmap.scale}, exp=${params.heightmap.exponent}, biomes=${params.biomes?.length}`);
const gen = new WorldGenerator(params);
// Маппинг материалов voxel-генератора → smooth (DensityGrid
// поддерживает только grass/rock/sand/snow).
// dirt → grass, остальные пропускаются.
const matMap = (m) => {
if (m === 'dirt') return 'grass';
if (m === 'grass' || m === 'rock' || m === 'sand' || m === 'snow') return m;
return 'grass';
};
// Шаг 1: heightmap + biome для каждой smooth-cell.
// x,z в smooth-grid — переводим в voxel-units: vx = (x + origin.x) * 16
const CELL_VOXELS = 16; // 4м / 0.25м per voxel = 16
const heightMap = new Float32Array(sx * sz);
const topMats = new Array(sx * sz);
const softMats = new Array(sx * sz);
const hardMats = new Array(sx * sz);
for (let z = 0; z < sz; z++) {
for (let x = 0; x < sx; x++) {
const vx = (x + grid.origin.x) * CELL_VOXELS + CELL_VOXELS / 2;
const vz = (z + grid.origin.z) * CELL_VOXELS + CELL_VOXELS / 2;
const hVoxels = gen.sampleHeight(vx, vz);
const biome = gen.sampleBiome(vx, vz);
const hCells = hVoxels / CELL_VOXELS;
heightMap[z * sx + x] = hCells;
topMats[z * sx + x] = matMap(biome.topMaterial);
softMats[z * sx + x] = matMap(biome.softMaterial);
hardMats[z * sx + x] = matMap(biome.hardMaterial);
}
}
// Шаг 2: density + материалы.
// Топ-слой: topMaterial биома.
// Средний (1..3 cells вглубь): softMaterial.
// Глубокий (>3 cells): hardMaterial.
for (let z = 0; z < sz; z++) {
for (let x = 0; x < sx; x++) {
const h = heightMap[z * sx + x];
const topMat = topMats[z * sx + x];
const softMat = softMats[z * sx + x];
const hardMat = hardMats[z * sx + x];
const h1 = x > 0 ? heightMap[z * sx + (x - 1)] : h;
const h2 = x < sx - 1 ? heightMap[z * sx + (x + 1)] : h;
const h3 = z > 0 ? heightMap[(z - 1) * sx + x] : h;
const h4 = z < sz - 1 ? heightMap[(z + 1) * sx + x] : h;
const slope = Math.max(
Math.abs(h - h1), Math.abs(h - h2),
Math.abs(h - h3), Math.abs(h - h4),
);
// На очень крутых обрывах (>3 cells = 12м перепад) —
// обнажение rock даже на травяных склонах.
const useRockSlope = slope > 3.0 && topMat !== 'sand' && topMat !== 'snow';
for (let y = 0; y < sy; y++) {
const delta = h - y;
let densityF;
if (delta > 2) densityF = 1;
else if (delta < -2) densityF = 0;
else densityF = (delta + 2) / 4;
const density = (densityF * 255) | 0;
if (density > 0) {
let mat;
if (useRockSlope) mat = 'rock';
else if (delta < 1) mat = topMat;
else if (delta < 3) mat = softMat;
else mat = hardMat;
grid.set(x, y, z, density, mat);
}
}
}
}
const tFill = performance.now() - t0;
console.log(`[RobloxTest] filled grid ${sx}×${sy}×${sz} (${grid.countSolid()} solid cells) in ${tFill.toFixed(0)}ms`);
rt.loadFromGrid(grid);
// Материализуем ВСЕ chunks сразу для теста.
const t1 = performance.now();
const camX = this.camera?.position.x || 0;
const camZ = this.camera?.position.z || 0;
const r = rt.updateStreaming(camX, camZ, 99999, { maxBuild: 9999 });
const tBuild = performance.now() - t1;
const stats = rt.getStats();
console.log(`[RobloxTest] built ${r.built} chunks in ${tBuild.toFixed(0)}ms — ${stats.triangles} triangles`);
// Мини-карта для свежесгенерированного гладкого ландшафта.
this._setupMinimapForRobloxTerrain();
// === Авто-спавн над поверхностью ===
// Находим в grid самую верхнюю solid-ячейку в столбце x=0, z=0.
// Spawn = top_y + 2м над ней.
const CS = 4; // CELL_SIZE
const cellX0 = 0 - grid.origin.x; // мировые (0,_,0) → cell
const cellZ0 = 0 - grid.origin.z;
let topCellY = -1;
for (let cy = sy - 1; cy >= 0; cy--) {
if (grid.isSolid(cellX0, cy, cellZ0)) { topCellY = cy; break; }
}
if (topCellY >= 0) {
const surfaceY = (grid.origin.y + topCellY + 1) * CS;
this._spawnPoint = { x: 0, y: surfaceY + 2, z: 0 };
this._updateSpawnMarker?.();
console.log(`[RobloxTest] auto-spawn at y=${surfaceY + 2} (surface at y=${surfaceY})`);
}
// Отключаем baseplate-пол — иначе он закрывает обзор и
// создаёт коллизии под smooth-ландшафтом.
try { this.setFloorEnabled(false); } catch (e) {}
// === Декорации (цветы / трава / грибы) ===
// Размещаем 3D-модели Kenney Nature Kit через thin-instances.
// Используем те же sampleHeight/sampleBiome из WorldGenerator
// что и для terrain — биомы определят какие декорации куда идут.
const decoOpts = userParams?.smoothDeco ?? {
flowersDensity: 0.025,
grassDensity: 0.10,
treesDensity: 0.4,
};
// Сохраняем параметры для сериализации (при load воссоздадим)
this._smoothDecoParams = {
flowersDensity: decoOpts.flowersDensity,
grassDensity: decoOpts.grassDensity,
treesDensity: decoOpts.treesDensity ?? 0.4,
seed: params.seed || 1337,
bbox: {
minX: -(sx * CS) / 2, maxX: (sx * CS) / 2,
minZ: -(sz * CS) / 2, maxZ: (sz * CS) / 2,
},
// Параметры WorldGenerator нужны для воссоздания biome-маппинга
genParams: params,
};
if (decoOpts.flowersDensity > 0 || decoOpts.grassDensity > 0 || (decoOpts.treesDensity ?? 0) > 0) {
try {
if (!this._smoothDecoManager) {
this._smoothDecoManager = new SmoothDecoManager(this.scene);
}
const tDeco0 = performance.now();
await this._smoothDecoManager.loadAll();
const tDecoLoad = performance.now() - tDeco0;
// bbox в мировых координатах (метры)
const halfMeters = (sx * CS) / 2;
const bbox = {
minX: -halfMeters, maxX: halfMeters,
minZ: -halfMeters, maxZ: halfMeters,
};
// Хелпер для surface raycast (использует физику)
const sampleSurfaceY = (x, z) => {
if (!this.physics?._sampleRobloxSurface) return null;
return this.physics._sampleRobloxSurface(x, z);
};
const sampleBiomeId = (x, z) => {
// x,z в метрах → voxel-units (×4)
const vx = x * 4;
const vz = z * 4;
const biome = gen.sampleBiome(vx, vz);
return biome?.id;
};
const tDeco1 = performance.now();
const r = this._smoothDecoManager.placeDecorations({
sampleSurfaceY, sampleBiomeId, bbox,
densityFlowers: decoOpts.flowersDensity,
densityGrass: decoOpts.grassDensity,
densityTrees: decoOpts.treesDensity ?? 0,
seed: params.seed || 1337,
});
const tDecoPlace = performance.now() - tDeco1;
console.log(`[RobloxTest] decorations: load ${tDecoLoad.toFixed(0)}ms + place ${tDecoPlace.toFixed(0)}ms → ${r.total} instances`);
// Регистрация tree-AABB в физике — игрок не пройдёт сквозь стволы.
if (this.physics?.setSmoothDecoTrees && r.treeColliders) {
this.physics.setSmoothDecoTrees(r.treeColliders);
}
} catch (e) {
console.error('[RobloxTest] decorations failed:', e);
}
}
// Перемещаем редактор-камеру повыше чтобы видеть весь рельеф
if (this.camera && topCellY >= 0) {
const surfaceY = (grid.origin.y + topCellY + 1) * CS;
this.camera.position.x = sx * CS * 0.3;
this.camera.position.y = surfaceY + 30;
this.camera.position.z = sz * CS * 0.3;
this.camera.setTarget?.(new Vector3(0, surfaceY, 0));
}
};
window.__robloxTestClear = () => this.clearRobloxTerrain();
// Этап 7a: процедурный генератор
// window.__voxelGenerate({size:160, params:{...}}) — генерирует
// террейн в bbox [-size..+size] и заменяет существующий terrain.
window.__voxelGenerate = async (opts = {}) => {
// ГЛОБАЛЬНЫЙ лок (на window, не на this!).
// Без него при HMR (hot module reload в dev) каждая копия
// BabylonScene имеет свой this._voxelGenerating, и команда
// в консоли вызывает все копии параллельно.
// window.__voxelGenLock виден ВСЕМ копиям сцены.
if (window.__voxelGenLock) {
console.warn('[VoxelGen] already running, ignoring duplicate call');
return null;
}
window.__voxelGenLock = true;
// size — half-size в voxel-units (0.25м/voxel).
// Картa = size × 2 × 0.25м.
// size=160 → 80×80м (~200K voxels, FPS 27) ← по умолчанию
// size=200 → 100×100м (~400K voxels, FPS 25) ← МАКСИМУМ
// Жёсткий лимит — 200 (карта 100×100м максимум).
// Для больших карт используйте Roblox-style smooth terrain.
try {
const MAX_SIZE = 200;
let size = opts.size ?? 160;
if (size > MAX_SIZE) {
console.warn(`[VoxelGen] size=${size} превышает лимит ${MAX_SIZE} (карта >100м). Обрезаю до ${MAX_SIZE}.`);
size = MAX_SIZE;
}
const params = opts.params ?? DEFAULT_GENERATOR_PARAMS;
// Сохраняем для мини-карты (MinimapOverlay читает window.__lastGenParams)
window.__lastGenParams = params;
window.__lastGenSize = size;
console.log(`[VoxelGen] generating ${size*2}×${size*2} voxel-units (${(size * 2 * 0.25).toFixed(0)}m × ${(size * 2 * 0.25).toFixed(0)}m)…`);
// ВАЖНО: пишем в LEGACY TerrainManager — он рендерит правильно
// (MultiCube для grass:top/side/bottom работает, текстуры
// настроены корректно). VoxelWorld остаётся как shadow-copy
// для RLE-сжатия в БД, но не для рендера.
//
// VoxelRenderer (новый) пока что выключен — он показывал
// серую кашу из-за проблем с MultiCube.
this._voxelRenderEnabled = false;
if (this.voxelRenderer) {
this.voxelRenderer.dispose();
this.voxelRenderer = new VoxelRenderer(this.voxelWorld, this.scene);
this.voxelRenderer.setOnMeshCreated((m) => this.addShadowCaster(m));
}
// Progress callback — UI подхватывает через window.__voxelGenProgress.
const onProgress = (done, total, phase) => {
const pct = Math.min(100, Math.round((done / total) * 100));
if (window.__voxelGenProgress) {
try { window.__voxelGenProgress(pct, phase); } catch (e) {}
}
};
onProgress(0, 100, 'starting');
// Этап C оптимизации: генерация в Web Worker'е.
// Main thread не блокируется, UI отзывчив, progress-bar плавный.
const { getTerrainGenWorkerUrl } = await import('./TerrainGenWorker');
const workerUrl = getTerrainGenWorkerUrl();
const worker = new Worker(workerUrl);
let voxels, decorations, treesPlaced, statsTimeMs;
try {
await new Promise((resolve, reject) => {
worker.onmessage = (e) => {
const m = e.data;
if (m.type === 'progress') {
onProgress(m.done, m.total, m.phase);
} else if (m.type === 'done') {
voxels = m.voxels;
decorations = m.decorations;
treesPlaced = m.treesPlaced;
statsTimeMs = m.timeMs;
resolve();
} else if (m.type === 'error') {
reject(new Error('[Worker] ' + m.message));
}
};
worker.onerror = (err) => {
reject(new Error('[Worker] crash: ' + err.message));
};
worker.postMessage({
type: 'generate',
params,
bbox: { x0: -size, z0: -size, x1: size, z1: size },
});
});
} finally {
worker.terminate();
URL.revokeObjectURL(workerUrl);
}
console.log(`[VoxelGen] generated ${voxels.length} voxels in ${statsTimeMs}ms (worker), ${treesPlaced} trees, ${decorations?.length || 0} decorations`);
onProgress(95, 100, 'render');
await new Promise(r => setTimeout(r, 0));
// Заливаем в legacy TerrainManager (он отрендерит правильно).
// Очищаем TerrainMesh если был — на новых генерациях не нужен.
if (this._terrainMesh) {
try { this._terrainMesh.disposeAll(); } catch (e) {}
}
if (this.terrainManager) {
this.terrainManager.clear();
if (this.terrainManager.loadFromArray.constructor.name === 'AsyncFunction') {
await this.terrainManager.loadFromArray(voxels);
} else {
this.terrainManager.loadFromArray(voxels);
}
console.log(`[VoxelGen] loaded into legacy TerrainManager`);
// Также пишем в VoxelWorld для RLE-сжатия в БД
try {
const vwLayer = this.voxelWorld.getOrCreateLayer('terrain', 0.25);
vwLayer.clear();
vwLayer.loadFromArray(voxels);
} catch (e) { /* ignore */ }
// АВТОВКЛЮЧЕНИЕ STREAMING для больших карт.
const regionCount = this.terrainManager.getRegionCount?.() ?? 0;
if (regionCount > 0) {
this._terrainStreamingEnabled = true;
// Адаптивный radius по количеству вокселей: чем больше
// карта, тем меньше radius (иначе слишком много рендерится).
// <300K voxels → 40м (норма для маленьких карт)
// 300K-1M → 36м
// 1M-2M → 32м
// >2M → 28м (очень большие)
const vc = voxels.length;
let radius = 40;
if (vc > 2_000_000) radius = 28;
else if (vc > 1_000_000) radius = 32;
else if (vc > 300_000) radius = 36;
this._terrainStreamingRadius = radius;
this._terrainStreamingLastUpdate = 0;
const cam = this.camera;
if (cam) {
const r = this.terrainManager.updateStreaming(cam.position.x, cam.position.z, this._terrainStreamingRadius);
console.log(`[VoxelGen] streaming ON: radius=${this._terrainStreamingRadius}m (${vc} voxels), ${r.enabled}/${r.total} regions enabled`);
}
} else {
this._terrainStreamingEnabled = false;
}
}
// Этап 6: загружаем decorations (мини-воксельные цветы/грибы).
if (this.decoManager && decorations) {
this.decoManager.loadFromArray(decorations);
// Этап D: первый pass LOD streaming для деко.
// maxBuild=2 — деко достроятся плавно через updateStreaming.
if (this.camera && this.decoManager.updateStreaming) {
const decoRadius = Math.max(18, (this._terrainStreamingRadius || 40) * 0.35);
this.decoManager.updateStreaming(
this.camera.position.x, this.camera.position.z, decoRadius,
{ maxBuild: 2 },
);
}
}
onProgress(100, 100, 'done');
return { voxels: voxels.length, treesPlaced, decorations: decorations?.length || 0, timeMs: statsTimeMs };
} finally {
// Снять глобальный лок ОБЯЗАТЕЛЬНО.
window.__voxelGenLock = false;
}
};
// Готовые пресеты для быстрого теста
window.__voxelPresets = {
default: DEFAULT_GENERATOR_PARAMS,
mountains: {
...DEFAULT_GENERATOR_PARAMS,
heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 50, exponent: 2.0 },
},
flat: {
...DEFAULT_GENERATOR_PARAMS,
heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 3, exponent: 1.0 },
},
islands: {
...DEFAULT_GENERATOR_PARAMS,
heightmap: { ...DEFAULT_GENERATOR_PARAMS.heightmap, amplitude: 15, exponent: 2.5 },
},
forest: {
...DEFAULT_GENERATOR_PARAMS,
biomes: DEFAULT_GENERATOR_PARAMS.biomes.map(b =>
b.id === 'plain' || b.id === 'forest'
? { ...b, features: { ...b.features, trees: 1.5 } }
: b,
),
},
};
// Этап 4: streaming контроль
window.__voxelWorldStreaming = (enabled, radius = 64) => {
this._voxelStreamingEnabled = !!enabled;
this._voxelStreamingRadius = radius;
if (!this._voxelRenderEnabled) {
console.log('[VoxelWorld] streaming on, но render выключен. Включи render: window.__voxelWorldRender(true)');
return;
}
if (!this._voxelStreamingEnabled) {
// Сброс: загрузить все чанки обратно
this.voxelRenderer.rebuildAll();
console.log('[VoxelWorld] streaming OFF — все чанки видимы');
return;
}
// Стартовый update вокруг камеры
const cam = this.camera;
const center = { x: cam.position.x, z: cam.position.z };
const r = this.voxelRenderer.updateStreaming(center, radius);
console.log(`[VoxelWorld] streaming ON, radius=${radius}m: ${r.loaded} loaded, ${r.unloaded} unloaded, ${this.voxelRenderer.getActiveChunkCount()}/${r.total} active`);
};
// Этап 3 benchmark: сравнить размер legacy JSON vs RLE+base64
window.__voxelWorldBenchmarkRLE = () => {
const t0 = performance.now();
const rleData = this.voxelWorld.serialize();
const t1 = performance.now();
const rleJson = JSON.stringify(rleData);
const t2 = performance.now();
const rleBytes = new Blob([rleJson]).size;
// Legacy формат для сравнения — массив {x,y,z,m}
const legacyVoxels = [];
const layer = this.voxelWorld.getLayer('terrain');
if (layer) {
for (const ch of layer.chunks.values()) {
const ox = ch.voxelOriginX();
const oy = ch.voxelOriginY();
const oz = ch.voxelOriginZ();
for (let i = 0; i < 32768; i++) {
const idx = ch.data[i];
if (idx === 0) continue;
const m = layer.matIdxToId(idx);
const lx = i % 32;
const lz = ((i / 32) | 0) % 32;
const ly = (i / 1024) | 0;
legacyVoxels.push({ x: ox + lx, y: oy + ly, z: oz + lz, m });
}
}
}
const t3 = performance.now();
const legacyJson = JSON.stringify(legacyVoxels);
const legacyBytes = new Blob([legacyJson]).size;
const t4 = performance.now();
const ratio = (legacyBytes / rleBytes).toFixed(1);
const sizes = this.voxelWorld.measureSize();
console.log('[RLE Benchmark]');
console.log(` Legacy JSON: ${(legacyBytes / 1024).toFixed(0)} KB (serialize: ${(t4 - t2).toFixed(0)} ms)`);
console.log(` RLE+base64: ${(rleBytes / 1024).toFixed(0)} KB (serialize: ${(t2 - t0).toFixed(0)} ms)`);
console.log(` Уменьшение: ${ratio}× меньше`);
console.log(' Подробно:', sizes);
return { legacy: legacyBytes, rle: rleBytes, ratio };
};
// Чистый benchmark mesh-build без создания Babylon-meshей. Это
// показывает скорость алгоритма greedy в отрыве от GPU.
window.__voxelWorldBenchmark = async () => {
const { buildChunkGeometryGreedy } = await import('./voxel/GreedyMesher');
const { buildChunkGeometry } = await import('./voxel/ChunkMesher');
const layer = this.voxelWorld.getLayer('terrain');
if (!layer) { console.warn('no terrain layer'); return; }
const neighborMatIdx = (gx, gy, gz) => layer.getMatIdx(gx, gy, gz);
// Surface culling (Этап 1)
let totalFacesNonGreedy = 0;
const t1 = performance.now();
for (const ch of layer.chunks.values()) {
const r = buildChunkGeometry(ch, layer, neighborMatIdx);
totalFacesNonGreedy += r.totalFaces;
}
const dt1 = performance.now() - t1;
// Greedy (Этап 2)
let totalFacesGreedy = 0;
const t2 = performance.now();
for (const ch of layer.chunks.values()) {
const r = buildChunkGeometryGreedy(ch, layer, neighborMatIdx);
totalFacesGreedy += r.totalFaces;
}
const dt2 = performance.now() - t2;
const reduction = ((1 - totalFacesGreedy / totalFacesNonGreedy) * 100).toFixed(1);
console.log(`[Benchmark] Surface culling: ${totalFacesNonGreedy} quads in ${dt1.toFixed(0)}ms`);
console.log(`[Benchmark] Greedy meshing: ${totalFacesGreedy} quads in ${dt2.toFixed(0)}ms — на ${reduction}% меньше квадров`);
return { surfaceCulling: { quads: totalFacesNonGreedy, ms: dt1 },
greedy: { quads: totalFacesGreedy, ms: dt2 },
reduction: `${reduction}%` };
};
window.__voxelWorldRender = (enabled) => {
this._voxelRenderEnabled = !!enabled;
if (this._voxelRenderEnabled) {
// Прячем legacy TerrainManager mesh'и
for (const proto of this.terrainManager._protoMeshes?.values?.() ?? []) {
proto.setEnabled(false);
}
// Если streaming ON — грузим только видимые чанки
// (rebuildAll потом бы их сразу half-выгрузил, лишняя работа).
if (this._voxelStreamingEnabled && this.camera) {
const cam = this.camera;
const r = this.voxelRenderer.updateStreaming(
{ x: cam.position.x, z: cam.position.z },
this._voxelStreamingRadius,
);
console.log(`[VoxelWorld] render ENABLED (streaming): ${r.loaded} loaded, ${this.voxelRenderer.getActiveChunkCount()}/${r.total} active`);
} else {
this.voxelRenderer.rebuildAll();
console.log('[VoxelWorld] render ENABLED, legacy hidden');
}
} else {
// Показываем legacy обратно, скрываем новый
for (const proto of this.terrainManager._protoMeshes?.values?.() ?? []) {
proto.setEnabled(true);
}
this.voxelRenderer.dispose();
console.log('[VoxelWorld] render DISABLED, legacy restored');
}
};
}
// Состояние brush'а ландшафта, обновляется из TerrainPanel.
// tool — 'select'|'transform'|'fill'|'sealevel'|'draw'|'sculpt'|'smooth'|'paint'|'flatten'
// material — id из TERRAIN_MATERIALS
// brushSize — радиус кисти в voxel'ах
// strength — 1..100
// shape — 'sphere'|'cube'|'cylinder'
this._terrainBrush = {
tool: 'sculpt',
material: 'grass',
brushSize: 4,
strength: 50,
shape: 'sphere',
// terrainMode: 'voxel' (по умолчанию) | 'smooth'.
// В smooth-режиме кисти редактируют DensityGrid через SmoothBrushes,
// в voxel — TerrainManager.voxels (как раньше).
terrainMode: 'voxel',
};
// Полупрозрачный preview-меш под курсором (показывает где будет кисть)
this._terrainBrushPreview = null;
this.modelManager = new ModelManager(this.scene);
this.modelManager.setScene3D(this);
// Делаем ModelManager доступным через scene — MultiplayerSync.js
// подхватывает его для shared-кэша GLB-прототипов.
this.scene._kubikonModelManager = this.modelManager;
// Этап 5 редактора моделей: менеджер пользовательских voxel-моделей.
// API подключается отдельно через setUserModelsApi (см. ниже),
// потому что Kubikon3DService импортируется через ES-modules.
this.userModelManager = new UserModelManager(this.scene);
// Глобальная функция для отладки: window.__kubikonDebugColliders()
// выводит в консоль все коллайдеры моделей и примитивов.
if (typeof window !== 'undefined') {
window.__kubikonDebugColliders = () => {
const out = [];
if (this.modelManager) {
for (const data of this.modelManager.instances.values()) {
const a = data.localAABB;
if (a) {
const w = (a.maxX - a.minX).toFixed(2);
const h = (a.maxY - a.minY).toFixed(2);
const d = (a.maxZ - a.minZ).toFixed(2);
out.push({
kind: 'model', id: data.modelTypeId,
pos: [data.x.toFixed(1), data.y.toFixed(1), data.z.toFixed(1)],
size: [w, h, d],
canCollide: data.canCollide,
});
}
}
}
if (this.primitiveManager) {
for (const data of this.primitiveManager.instances.values()) {
out.push({
kind: 'primitive', type: data.type,
pos: [data.x.toFixed(1), data.y.toFixed(1), data.z.toFixed(1)],
size: [data.sx, data.sy, data.sz],
canCollide: data.canCollide,
});
}
}
console.table(out);
return out;
};
}
this.primitiveManager = new PrimitiveManager(this.scene);
// Ссылка на обёртку BabylonScene — нужна для эмиттеров частиц
// (createEmitterParticles живёт на обёртке).
this.primitiveManager.scene3d = this;
// BillboardUiManager — отдельный модуль, рисует GUI на DynamicTexture
// для билбордов. Нужен PrimitiveManager-у чтобы при создании billboard
// (type='billboard') сразу применить текстуру с дефолтным пресетом.
this.billboardUiManager = new BillboardUiManager(this.scene);
this.primitiveManager.billboardUiManager = this.billboardUiManager;
this.folderManager = new FolderManager(this.blockManager, this.modelManager, this.primitiveManager);
this.guiManager = new GuiManager();
this.modalManager = new ModalManager();
this.modalManager.attachScene(this);
this.modalManager.attachGui(this.guiManager);
this.inventory = new InventoryManager();
this.physics = new PhysicsAABB(this.blockManager);
this.physics.setPrimitiveManager(this.primitiveManager);
this.physics.setModelManager(this.modelManager);
this.physics.setUserModelManager(this.userModelManager);
// Voxel-террейн тоже участвует в физике. У террейна свой размер
// ячейки (TERRAIN_VOXEL_SIZE = 0.5), поэтому передаём отдельно.
this.physics.setTerrainManager(this.terrainManager, TERRAIN_VOXEL_SIZE);
// Этап 4 voxel-движка: подключаем новый chunk-based VoxelWorld к физике.
// Физика проверяет коллизии в обоих источниках (legacy terrainManager +
// voxelWorld), что позволяет постепенно мигрировать без поломки.
if (this.physics.setVoxelWorld && this.voxelWorld) {
this.physics.setVoxelWorld(this.voxelWorld);
}
this.dynamics = new DynamicsManager(this);
this.environment = new Environment(this.scene, this._hemiLight, this._sunLight);
this.audioManager = new AudioManager();
this.assetManager = new AssetManager();
// PrimitiveManager должен уметь брать dataURL картинки по id ассета,
// чтобы применять пользовательскую текстуру на грани примитива.
this.primitiveManager.assetManager = this.assetManager;
// Библиотека пользовательских звуков (Фаза 5.5) — постоянная.
this.soundLibrary = new SoundLibrary();
// Библиотека импортированных .glb-моделей (Фаза 5.8) — постоянная.
this.glbLibrary = new GlbLibrary();
this.selection = new SelectionManager(this.scene, this.blockManager, this.modelManager);
this.selection.setPrimitiveManager(this.primitiveManager);
this.selection.setUserModelManager(this.userModelManager);
this.selection.setScene3D(this);
// === Обработка кликов по 3D-табличкам (billboard) в Play-режиме ===
// При клике луч из позиции курсора (либо из центра экрана, если игрок
// в pointer-lock) → ищем под ним меш типа billboard → переводим точку
// пересечения в UV → BillboardUiManager.pickButtonAt → fireClick.
// Работает только когда _isPlaying=true (в редакторе клик гизмо или ничего).
// Прямой capture-phase mousedown на canvas — раньше PlayerController.
// Babylon onPointerObservable не получает события в pointer-lock,
// поэтому ловим сами и стреляем лучом по табличкам в Play.
const canvasEl = this.canvas;
const onBillboardMouseDown = (e) => {
if (!this._isPlaying) return;
if (e.button !== 0) return;
const inLock = (document.pointerLockElement != null);
let px, py;
if (inLock) {
px = this.engine.getRenderWidth() / 2;
py = this.engine.getRenderHeight() / 2;
} else {
const rect = canvasEl.getBoundingClientRect();
px = e.clientX - rect.left;
py = e.clientY - rect.top;
}
const pi = this.scene.pick(px, py, (m) => {
return m.metadata?.isPrimitive
&& this.primitiveManager.instances.get(m.metadata.primitiveId)?.type === 'billboard';
});
if (!pi || !pi.hit || !pi.pickedMesh) return;
const meta = pi.pickedMesh.metadata;
const data = this.primitiveManager.instances.get(meta.primitiveId);
if (!data || data.type !== 'billboard') return;
const uv = pi.getTextureCoordinates ? pi.getTextureCoordinates() : null;
if (!uv) return;
const buttonId = this.billboardUiManager.pickButtonAt(data, uv.x, uv.y);
if (buttonId) {
this.billboardUiManager.fireClick(data, buttonId);
// Предотвращаем PlayerController-обработчик (pointer-lock и т.д.)
e.stopPropagation();
e.preventDefault();
}
};
canvasEl.addEventListener('mousedown', onBillboardMouseDown, true /* capture */);
// GizmoController — управляет 3 типами гизмо (move/rotate/scale).
// UtilityLayer — отдельный слой, чтобы гизмо рисовалось поверх сцены.
// Babylon автоматически активирует pointer-observable utility-сцены
// когда родительская scene control включён (мы убрали detachControl).
this._gizmoLayer = new UtilityLayerRenderer(this.scene);
this._gizmo = new GizmoController(this._gizmoLayer, this.scene);
this._gizmo.setMode('select'); // по умолчанию — без манипулятора
this._gizmo.setSnap(1.0); // снэп для блоков
// При окончании drag — синхронизируем
this._gizmo.setOnDragEnd(() => this._onGizmoDragEnd());
// Привязка гизмо к выделенному
this.selection.setOnSelectionChange((sel) => this._updateGizmoForSelection(sel));
// History (Undo/Redo). Сериализатор и восстановитель — методы этой сцены.
this.history = new HistoryManager(
() => {
try { return JSON.stringify(this.serialize()); }
catch (e) { return null; }
},
async (state) => {
// При undo/redo — снимаем выделение (mesh может быть пересоздан)
this.selection?.clear();
await this.loadFromState(state);
}
);
// На любые изменения сцены — markChange (debounced)
this.blockManager.setOnChange(() => {
this.history?.markChange();
if (this._onSceneChange) this._onSceneChange();
});
this.modelManager.setOnChange(() => {
this.history?.markChange();
// Сбрасываем spatial-индекс физики — модели могли двигаться/добавляться.
this.physics?.setSpatialDirty?.();
if (this._onSceneChange) this._onSceneChange();
});
this.primitiveManager.setOnChange(() => {
this.history?.markChange();
this.physics?.setSpatialDirty?.();
if (this._onSceneChange) this._onSceneChange();
});
// Этап 5: подписка на изменения user-моделей.
this.userModelManager.setOnChange(() => {
this.history?.markChange();
this.physics?.setSpatialDirty?.();
if (this._onSceneChange) this._onSceneChange();
});
this.folderManager.setOnChange(() => {
this.history?.markChange();
if (this._onSceneChange) this._onSceneChange();
});
this.guiManager.setOnChange(() => {
this.history?.markChange();
if (this._onSceneChange) this._onSceneChange();
if (this._onGuiChange) this._onGuiChange();
// Если в Play — обновляем зеркало в Worker'ах сразу
if (this._isPlaying && this.gameRuntime) {
this.gameRuntime.scheduleGuiSnapshot();
}
});
this.inventory.setOnChange(() => {
this.history?.markChange();
if (this._onSceneChange) this._onSceneChange();
if (this._onInventoryChange) this._onInventoryChange();
});
// Запоминаем начальное (пустое) состояние как точку для undo.
this.history.initialize();
this.engine.runRenderLoop(() => {
// Если рендер на паузе (например, активен таб скрипта или вкладка
// браузера в фоне) — пропускаем тик целиком. Освобождаем CPU/GPU
// для Monaco, который иначе лагает на ввод.
if (this._renderingPaused) return;
if (this.scene && this.scene.activeCamera) {
this._updateCameraMovement();
this._updateGhostPosition();
const dt = this.engine.getDeltaTime() / 1000;
// Физика unanchored-объектов в Play-режиме
if (this._isPlaying && this.dynamics?.isEnabled()) {
this.dynamics.tick(dt);
}
// Цикл дня/ночи (только в Play-режиме, чтобы редактор не «убегал»)
if (this._isPlaying && this.environment) {
this.environment.tick(dt);
}
// Анимация жидкостей — работает всегда (и в редакторе)
if (this.blockManager) {
this.blockManager.tick(dt);
}
// LOD/culling далёких моделей (раз в 5 кадров — экономим CPU)
this._lodFrameCounter = (this._lodFrameCounter || 0) + 1;
if (this._lodFrameCounter % 5 === 0) {
this._updateModelLOD();
// Примитивы НЕ култим по дистанции — на компактных сценах
// (Squid Game) это убирает куклу/охранников вдали и пользователь
// видит пустое поле. Лучшее решение — пусть Babylon
// frustum-cull'ит сам, у нас уже freezeWorldMatrix.
}
// Этап 4 voxel-streaming: подгрузка/выгрузка чанков по радиусу
// от игрока (в Play) или камеры (в редакторе). Дёргаем раз в
// 250мс — этого достаточно при ходьбе.
// VoxelWorld streaming (новый рендер) — disabled by default,
// используется TerrainManager streaming ниже (legacy подход).
if (this._voxelStreamingEnabled && this._voxelRenderEnabled && this.voxelRenderer) {
const nowMs = performance.now();
if (nowMs - this._voxelStreamingLastUpdate > 200) {
this._voxelStreamingLastUpdate = nowMs;
let cx, cz;
if (this._isPlaying && this.player && this.player._pos) {
cx = this.player._pos.x; cz = this.player._pos.z;
} else if (this.camera && this.camera.position) {
cx = this.camera.position.x; cz = this.camera.position.z;
}
if (cx !== undefined) {
this.voxelRenderer.updateStreaming({ x: cx, z: cz }, this._voxelStreamingRadius);
}
}
}
// === LEGACY TerrainManager streaming (region-meshes) ===
// Главный механизм производительности для больших карт:
// enable/disable region-meshes legacy террейна по радиусу
// от игрока/камеры. Регионы за пределами radius — disabled,
// не рендерятся GPU.
if (this._terrainStreamingEnabled && this.terrainManager?.updateStreaming) {
const nowMs2 = performance.now();
// 200мс — реже чем раньше (было 80мс). Streaming = тяжёлая
// операция (обход всех region-meshes), не нужна каждые 80мс.
if (nowMs2 - (this._terrainStreamingLastUpdate || 0) > 200) {
this._terrainStreamingLastUpdate = nowMs2;
let cx, cz;
let radius = this._terrainStreamingRadius || 60;
if (this._isPlaying && this.player && this.player._pos) {
cx = this.player._pos.x; cz = this.player._pos.z;
} else if (this.camera && this.camera.position) {
cx = this.camera.position.x; cz = this.camera.position.z;
const camY = this.camera.position.y || 0;
// Editor radius = play × 1.3 + height bonus. Capped 60м.
// Раньше было ×1.6 + 30 = до 85м (47 регионов в кадре = 14M trianglов).
// Сейчас 32 × 1.3 + 20 = до 60м (~20-25 регионов = ~5M trianglов).
const heightBonus = Math.max(0, Math.min(20, camY * 0.3));
radius = Math.min(60, radius * 1.3 + heightBonus);
}
// SKIP если камера не сдвинулась >3м с прошлого пересчёта
const prevX = this._terrainStreamingPrevX;
const prevZ = this._terrainStreamingPrevZ;
if (cx !== undefined && prevX !== undefined) {
const ddx = cx - prevX, ddz = cz - prevZ;
if (ddx * ddx + ddz * ddz < 9) { // < 3м
cx = undefined; // отменяет следующий блок
}
}
if (cx !== undefined) {
this._terrainStreamingPrevX = cx;
this._terrainStreamingPrevZ = cz;
}
if (cx !== undefined) {
this.terrainManager.updateStreaming(cx, cz, radius);
// Этап D: deco streaming с МЕНЬШИМ радиусом.
// Декорации видны только вблизи. Минимум 35м чтобы
// chunk 64м не пропадал — иначе видны «дыры».
if (this.decoManager?.updateStreaming) {
const decoRadius = Math.max(18, radius * 0.35);
this.decoManager.updateStreaming(cx, cz, decoRadius);
}
}
}
}
// Задача 04: modalManager.tick — независимо от runtime'а
if (this._isPlaying && this.modalManager?.tick) {
try { this.modalManager.tick(dt); } catch (e) {}
}
// Tick пользовательских скриптов: в Play-режиме или в solo-debug
if (this.gameRuntime && (this._isPlaying || this.gameRuntime.isSolo?.())) {
this.gameRuntime.tick(dt);
// Детекция touch-событий — раз в 3 кадра (для script onTouch).
// Это O(N×M) = скрипты × примитивы, на мобиле просаживало FPS
// при повороте. 3 кадра ≈ 50мс при 60fps — хватает для UX.
if (this._isPlaying) {
this._touchDetectFrame = (this._touchDetectFrame || 0) + 1;
if (this._touchDetectFrame >= 3) {
this._touchDetectFrame = 0;
this._detectTouchEvents();
}
}
}
// Анимация полоски перезарядки оружия
if (this._isPlaying && this.weapons) this.weapons.tick();
// PERF-METRICS: замеряем render() — обычно самая толстая часть.
const _rt0 = performance.now();
// Замер idle-времени = промежуток между концом предыдущего
// render и началом текущего. Большое idle = GPU-bound.
if (this._perfMetrics && this._perfMetrics._lastRenderEnd > 0) {
const idle = _rt0 - this._perfMetrics._lastRenderEnd;
if (idle > 0 && idle < 1000) {
this._perfMetrics.idle_ms_sum += idle;
this._perfMetrics.idle_count++;
}
}
this.scene.render();
if (this._perfMetrics) {
const _rt1 = performance.now();
this._perfMetrics.render_ms_sum += _rt1 - _rt0;
this._perfMetrics.render_count++;
this._perfMetrics._lastRenderEnd = _rt1;
}
}
});
// resize при изменении окна
this._resizeHandler = () => this.engine.resize();
window.addEventListener('resize', this._resizeHandler);
// Главное: ResizeObserver на canvas. React сначала рендерит canvas
// размером 0, потом раскладка применяет 100%/100%. Engine, созданный
// в момент init, считал размер 0 и backbuffer был пуст. Через RO
// ловим финальный размер и вызываем resize.
if (typeof ResizeObserver !== 'undefined') {
this._ro = new ResizeObserver(() => {
if (this.engine) this.engine.resize();
});
this._ro.observe(this.canvas);
}
// Принудительный resize чуть позже — на случай если RO не сработал
setTimeout(() => { if (this.engine) this.engine.resize(); }, 100);
}
/**
* UniversalCamera — позволяет ручное управление позицией и yaw/pitch.
* Стартовая позиция: смотрим на (0,0,0) сверху-сбоку.
*/
_createCamera() {
const camera = new UniversalCamera(
'editorCamera',
new Vector3(15, 15, -20),
this.scene
);
camera.setTarget(new Vector3(0, 0, 0));
camera.minZ = 0.1;
camera.maxZ = 1000;
camera.fov = 0.9;
// ОТКЛЮЧАЕМ стандартное управление — будем писать своё.
camera.inputs.clear();
this.camera = camera;
}
_createLights() {
const hemi = new HemisphericLight(
'hemiLight',
new Vector3(0, 1, 0),
this.scene
);
hemi.intensity = 0.65;
hemi.groundColor = new Color3(0.3, 0.3, 0.4);
const sun = new DirectionalLight(
'sunLight',
new Vector3(-0.5, -1, -0.3),
this.scene
);
sun.intensity = 0.8;
sun.position = new Vector3(20, 40, 20);
// Сохраняем ссылки чтобы Environment мог менять их свойства
this._hemiLight = hemi;
this._sunLight = sun;
// Тени — по умолчанию мягкие. Создаётся ShadowGenerator при первом
// вызове setShadowQuality, либо сразу через _ensureShadowGenerator.
// MOBILE-OPT (этап 1.5): на мобильном тени = 'hard' (жёсткие — без
// soft-blur, дешевле). 'off' давало плоскую картинку, теперь
// компромисс — есть тени но дешевле soft.
this._shadowQuality = this._isMobileMode ? 'hard' : 'soft';
this._shadowGenerator = null;
this._ensureShadowGenerator();
// SSAO2 — контактные тени (по умолчанию off, тяжёлый эффект).
this._ssaoPipeline = null;
this._ssaoEnabled = false;
}
/** Создаёт ShadowGenerator (если ещё нет) и применяет текущее качество.
*
* Уровни (Этап 2+5 теней, 2026-05-27):
* - 'off' — теней нет
* - 'hard' — резкие, 512px
* - 'soft' — мягкие, 1024px (на mobile 512), blurKernel 24
* - 'medium' — CSM 1024 × 3 каскада
* - 'high' — CSM 2048 × 4 каскада
*/
_ensureShadowGenerator() {
const q = this._shadowQuality;
if (q === 'off') {
if (this._shadowGenerator) {
try { this._shadowGenerator.dispose(); } catch (e) { /* ignore */ }
this._shadowGenerator = null;
}
return null;
}
const wantCsm = (q === 'medium' || q === 'high');
const haveCsm = this._shadowGenerator instanceof CascadedShadowGenerator;
if (this._shadowGenerator && wantCsm !== haveCsm) {
try { this._shadowGenerator.dispose(); } catch (e) { /* ignore */ }
this._shadowGenerator = null;
}
// PCF = Percentage Closer Filtering. Это правильная техника мягких
// теней (контурное сглаживание по shadow-map).
//
// bias/normalBias подобраны под воксельную графику:
// bias = 0.0005 — сдвиг по глубине, убирает acne на flat-граней
// normalBias = 0.005 — сдвиг вдоль нормали, убирает acne на острых
// кромках. БОЛЬШИЕ значения дают peter-panning
// (тень отрывается от объекта и улетает в
// сторону солнца). Было 0.012 → длинная
// "уехавшая" тень на скрине пользователя
// 2026-05-27. 0.005 — золотая середина для
// кубов 1м с прямыми гранями.
const PCF_BIAS = 0.0005;
const PCF_NORMAL_BIAS = 0.005;
if (!this._shadowGenerator) {
if (wantCsm) {
// CSM: больше разрешение каскадов — тени детальнее.
const size = (q === 'high') ? 4096 : 2048;
const numCascades = (q === 'high') ? 4 : 3;
const csm = new CascadedShadowGenerator(size, this._sunLight);
csm.numCascades = numCascades;
csm.stabilizeCascades = true;
csm.lambda = 0.8;
csm.cascadeBlendPercentage = 0.07;
csm.shadowMaxZ = (q === 'high') ? 200 : 120;
csm.bias = PCF_BIAS;
csm.normalBias = PCF_NORMAL_BIAS;
csm.usePercentageCloserFiltering = true;
csm.filteringQuality = (q === 'high')
? ShadowGenerator.QUALITY_HIGH
: ShadowGenerator.QUALITY_MEDIUM;
csm.darkness = 0.4;
csm.autoCalcDepthBounds = true;
this._shadowGenerator = csm;
} else {
// Обычный ShadowGenerator. Поднял разрешение для soft до 2048.
let shadowSize;
if (q === 'hard') {
shadowSize = this._isMobileMode ? 512 : 1024;
} else { // soft
shadowSize = this._isMobileMode ? 1024 : 2048;
}
const gen = new ShadowGenerator(shadowSize, this._sunLight);
gen.bias = PCF_BIAS;
gen.normalBias = PCF_NORMAL_BIAS;
if (gen.getShadowMap) {
const rtt = gen.getShadowMap();
if (rtt) rtt.refreshRate = 2;
}
this._shadowGenerator = gen;
}
}
const gen = this._shadowGenerator;
if (q === 'medium' || q === 'high') {
// параметры CSM выставлены при создании (PCF + high quality)
} else if (q === 'soft') {
// PCF medium quality — мягкие тени без шумного блюра
gen.usePercentageCloserFiltering = true;
gen.filteringQuality = ShadowGenerator.QUALITY_MEDIUM;
gen.useBlurExponentialShadowMap = false;
gen.useKernelBlur = false;
gen.usePoissonSampling = false;
gen.darkness = 0.4;
} else { // hard
gen.usePercentageCloserFiltering = false;
gen.useBlurExponentialShadowMap = false;
gen.useKernelBlur = false;
gen.usePoissonSampling = false;
gen.darkness = 0.55;
}
return gen;
}
/** Изменить качество теней. */
setShadowQuality(q) {
const allowed = ['off', 'hard', 'soft', 'medium', 'high'];
if (!allowed.includes(q)) return;
this._shadowQuality = q;
this._ensureShadowGenerator();
const ground = this.scene.getMeshByName('editorGround');
if (ground) ground.receiveShadows = q !== 'off';
if (q !== 'off') {
try { this.refreshAllShadows(); } catch (e) { /* ignore */ }
}
}
getShadowQuality() { return this._shadowQuality || 'soft'; }
/** Включить/выключить SSAO пост-эффект (контактные тени).
*
* Используем SSAORenderingPipeline v1 (не v2). v2 требует
* GeometryBufferRenderer (G-buffer для нормалей), что ломает рендер
* thin-instance мешей (BlockManager, TerrainManager) — блоки пропадают
* при включении. v1 использует только depthRenderer (одна depth-текстура),
* совместим со всеми типами мешей. Качество чуть хуже, но стабильно.
*/
setSsaoEnabled(on) {
const want = !!on;
if (this._ssaoEnabled === want && (!want || this._ssaoPipeline)) return;
if (!want) {
this._disposeSsaoPipeline();
this._ssaoEnabled = false;
return;
}
if (!this.scene.activeCamera) {
console.warn('[BabylonScene] SSAO: нет активной камеры');
return;
}
try {
// ratio: render-resolution для AO-пасса (0.5 = half-res, дешевле).
// combineRatio: финальная композиция всегда 1.0 (full-res).
const ratio = { ssaoRatio: 0.5, combineRatio: 1.0 };
const pipeline = new SSAORenderingPipeline(
'ssaopipeline', this.scene, ratio, [this.scene.activeCamera]
);
pipeline.fallOff = 0.000001;
pipeline.area = 0.0075;
pipeline.radius = 0.0001;
pipeline.totalStrength = 1.0; // насколько тёмные углы
pipeline.base = 0.5; // базовая яркость (1.0 = без AO)
this._ssaoPipeline = pipeline;
this._ssaoEnabled = true;
} catch (e) {
console.warn('[BabylonScene] SSAO не запустился:', e?.message || e);
this._disposeSsaoPipeline();
this._ssaoEnabled = false;
}
}
/** Полностью убрать SSAO пайплайн (detach + remove + dispose).
* Только dispose() оставляет null-объект в postProcessRenderPipelineManager
* → следующий кадр падает в null.isSupported. */
_disposeSsaoPipeline() {
if (!this._ssaoPipeline) return;
const mgr = this.scene.postProcessRenderPipelineManager;
const name = this._ssaoPipeline.name || 'ssaopipeline';
try {
if (mgr && this.scene.activeCamera) {
mgr.detachCamerasFromRenderPipeline?.(name, this.scene.activeCamera);
}
} catch (e) { /* ignore */ }
try {
// Удаляем из менеджера ДО dispose — иначе на следующем кадре
// менеджер обходит уже задиспозенный пайплайн.
if (mgr && typeof mgr.removePipeline === 'function') {
mgr.removePipeline(name);
}
} catch (e) { /* ignore */ }
try { this._ssaoPipeline.dispose(); } catch (e) { /* ignore */ }
this._ssaoPipeline = null;
}
getSsaoEnabled() { return this._ssaoEnabled; }
/**
* Установить свойства глобального освещения. Вызывается из Inspector
* (selection.type === 'lighting').
* patch: { sunIntensity?, hemiIntensity?, hemiGround?, fogEnabled?,
* fogDensity?, fogColor?, shadowQuality? }
*/
setLightingProps(patch) {
if (!patch) return;
// Время суток — пресет / минуты день/ночь
if (patch.envPreset && this.environment) {
try { this.environment.setPreset(patch.envPreset); } catch (e) { /* ignore */ }
}
if (typeof patch.dayDurationMin === 'number' && patch.dayDurationMin > 0 && this.environment) {
this.environment.setCycleDuration(patch.dayDurationMin, this.environment.nightDurationMin);
}
if (typeof patch.nightDurationMin === 'number' && patch.nightDurationMin > 0 && this.environment) {
this.environment.setCycleDuration(this.environment.dayDurationMin, patch.nightDurationMin);
}
if (typeof patch.sunIntensity === 'number' && this._sunLight) {
this._sunLight.intensity = Math.max(0, patch.sunIntensity);
}
if (typeof patch.hemiIntensity === 'number' && this._hemiLight) {
this._hemiLight.intensity = Math.max(0, patch.hemiIntensity);
}
if (this.environment && typeof this.environment.setFog === 'function') {
// Текущие значения берём из Environment, поверх накладываем patch
const enabled = (typeof patch.fogEnabled === 'boolean')
? patch.fogEnabled : this.environment.fogEnabled;
let color = this.environment.fogColor;
if (patch.fogColor && /^#[0-9a-fA-F]{6}$/.test(patch.fogColor)) {
color = [
parseInt(patch.fogColor.substr(1, 2), 16) / 255,
parseInt(patch.fogColor.substr(3, 2), 16) / 255,
parseInt(patch.fogColor.substr(5, 2), 16) / 255,
];
}
const density = (typeof patch.fogDensity === 'number')
? patch.fogDensity : this.environment.fogDensity;
if ('fogEnabled' in patch || 'fogDensity' in patch || 'fogColor' in patch) {
this.environment.setFog(enabled, color, density);
}
}
if (patch.shadowQuality) {
this.setShadowQuality(patch.shadowQuality);
this.refreshAllShadows();
}
if (typeof patch.ssaoEnabled === 'boolean') {
this.setSsaoEnabled(patch.ssaoEnabled);
}
// Обновить selection чтобы Inspector сразу показывал новые значения
if (this.selection?._selection?.type === 'lighting') {
this.selection.selectLighting();
}
}
/**
* Сгруппировать текущие выделенные объекты в новую папку (Ctrl+G).
* Если выделен один — кладёт его одного. Если ничего — no-op.
*/
groupSelected(name = null) {
if (!this.folderManager || !this.selection) return null;
const multi = this.selection.getMultiSelection();
const items = [];
if (multi.length > 0) {
for (const it of multi) items.push(it);
} else {
const s = this.selection.getSelection();
if (!s) return null;
if (s.type === 'block') items.push({ kind: 'block', ref: { x: s.gridX, y: s.gridY, z: s.gridZ } });
else if (s.type === 'model') items.push({ kind: 'model', ref: s.instanceId });
else if (s.type === 'primitive') items.push({ kind: 'primitive', ref: s.id });
else return null;
}
if (items.length === 0) return null;
const folderName = name || `Группа ${this.folderManager.getAll().length + 1}`;
const folderId = this.folderManager.createFolder(folderName, null);
for (const it of items) {
this.folderManager.assignToFolder(it.kind, it.ref, folderId);
}
return folderId;
}
/**
* Зарегистрировать меш как «отбрасывающий тень». Безопасно вызывать многократно.
* ВАЖНО: только настоящие Mesh (с геометрией), а не TransformNode-узлы.
* ShadowGenerator вызывает getBoundingInfo()/getVerticesData() — у TransformNode
* этих методов нет, что приводит к runtime-крашу.
*/
/** Удалено: пытались через ShadowGenerator, не сработало.
* Теперь тени делает GdGroundSkin через синтетические «тени-кружки». */
_enableGdShadows() { /* no-op */ }
addShadowCaster(mesh) {
if (!this._shadowGenerator || !mesh) return;
// TransformNode не имеет getBoundingInfo/getVerticesData
if (typeof mesh.getBoundingInfo !== 'function') return;
if (typeof mesh.getTotalVertices !== 'function') return;
if (mesh.getTotalVertices() <= 0) return;
try { this._shadowGenerator.addShadowCaster(mesh, false); } catch (e) { /* ignore */ }
}
/**
* Зарегистрировать все текущие блоки/модели/примитивы как shadow casters.
* Полезно вызвать после loadFromState или смены качества теней.
*/
refreshAllShadows() {
if (!this._shadowGenerator) return;
if (this.blockManager) {
// Для thin-instance proto-мешей: один addShadowCaster на тип = все инстансы
if (this.blockManager._protoMeshes) {
for (const proto of this.blockManager._protoMeshes.values()) {
this.addShadowCaster(proto);
}
}
// Жидкости/legacy mesh
for (const m of this.blockManager.blocks.values()) {
if (m && typeof m.getBoundingInfo === 'function') this.addShadowCaster(m);
}
}
if (this.modelManager) {
for (const inst of this.modelManager.instances.values()) {
const root = inst.rootMesh;
if (!root) continue;
// root обычно TransformNode → пропускаем сам root, добавляем только child-mesh'ы
if (typeof root.getChildMeshes === 'function') {
for (const cm of root.getChildMeshes()) this.addShadowCaster(cm);
}
}
}
if (this.primitiveManager) {
for (const inst of this.primitiveManager.instances.values()) {
if (inst.mesh) this.addShadowCaster(inst.mesh);
}
}
}
_createGroundGrid() {
// Размер мира — настраивается через setWorldSize(). Пол идёт от -WORLD_HALF до +WORLD_HALF.
const WORLD_HALF = this._worldHalf;
const ground = MeshBuilder.CreateGround(
'editorGround',
{ width: WORLD_HALF * 2, height: WORLD_HALF * 2, subdivisions: 1 },
this.scene
);
// Baseplate-текстура (как в Roblox Studio): процедурный клетчатый паттерн.
// Рисуем в DynamicTexture — каждая «плитка» 1×1 на грани соответствует
// 1 единице мира. Делаем 64×64 пикселей — каждый пиксель = 1 квадрат.
const TEX_SIZE = 64;
const baseTex = new DynamicTexture('baseplateTex', { width: TEX_SIZE, height: TEX_SIZE }, this.scene, false);
baseTex.wrapU = 1; // wrap
baseTex.wrapV = 1;
baseTex.uScale = WORLD_HALF * 2 / 4; // одна повторка покрывает 4 клетки
baseTex.vScale = WORLD_HALF * 2 / 4;
baseTex.updateSamplingMode(DynamicTexture.NEAREST_SAMPLINGMODE);
const ctx = baseTex.getContext();
// Серая основа
ctx.fillStyle = '#7a8071';
ctx.fillRect(0, 0, TEX_SIZE, TEX_SIZE);
// Тёмные «рейки» по периметру плитки (Roblox-style)
ctx.strokeStyle = '#5d6358';
ctx.lineWidth = 4;
ctx.strokeRect(2, 2, TEX_SIZE - 4, TEX_SIZE - 4);
// Тонкая внутренняя сетка 4 на плитке
ctx.strokeStyle = '#6c7268';
ctx.lineWidth = 1;
for (let i = 1; i < 4; i++) {
const p = (i * TEX_SIZE) / 4;
ctx.beginPath();
ctx.moveTo(p, 2); ctx.lineTo(p, TEX_SIZE - 2);
ctx.moveTo(2, p); ctx.lineTo(TEX_SIZE - 2, p);
ctx.stroke();
}
baseTex.update();
const mat = new StandardMaterial('groundMat', this.scene);
mat.diffuseTexture = baseTex;
mat.specularColor = new Color3(0, 0, 0);
ground.material = mat;
ground.receiveShadows = true;
// Только две осевые линии (X и Z), цветные — для ориентации в редакторе.
// Сетку делает сама baseplate-текстура.
const axisMatX = new StandardMaterial('axisMatX', this.scene);
axisMatX.diffuseColor = new Color3(0.85, 0.25, 0.25);
axisMatX.emissiveColor = new Color3(0.5, 0.1, 0.1);
axisMatX.specularColor = new Color3(0, 0, 0);
const axisMatZ = new StandardMaterial('axisMatZ', this.scene);
axisMatZ.diffuseColor = new Color3(0.25, 0.4, 0.85);
axisMatZ.emissiveColor = new Color3(0.1, 0.2, 0.5);
axisMatZ.specularColor = new Color3(0, 0, 0);
// Ось X (красная) — линия вдоль X на z=0
const axisX = MeshBuilder.CreateBox('axisX',
{ width: WORLD_HALF * 2, height: 0.02, depth: 0.1 }, this.scene);
axisX.position = new Vector3(0, 0.011, 0);
axisX.material = axisMatX;
axisX.isPickable = false;
this._gridLines = [axisX];
// Ось Z (синяя) — линия вдоль Z на x=0
const axisZ = MeshBuilder.CreateBox('axisZ',
{ width: 0.1, height: 0.02, depth: WORLD_HALF * 2 }, this.scene);
axisZ.position = new Vector3(0, 0.011, 0);
axisZ.material = axisMatZ;
axisZ.isPickable = false;
this._gridLines.push(axisZ);
}
/**
* Изменить размер пола (worldSize × worldSize). Пересоздаёт пол и осевые линии.
* @param {number} worldSize — полный размер стороны пола в юнитах (например 100, 200, 500).
*/
setWorldSize(worldSize) {
const half = Math.max(10, Math.round(worldSize / 2));
if (half === this._worldHalf) return;
this._worldHalf = half;
// Удалить старый пол + осевые линии
const oldGround = this.scene.getMeshByName('editorGround');
if (oldGround) try { oldGround.dispose(); } catch (e) { /* ignore */ }
if (Array.isArray(this._gridLines)) {
for (const line of this._gridLines) {
try { line.dispose(); } catch (e) { /* ignore */ }
}
}
this._gridLines = [];
this._createGroundGrid();
}
/** Текущий размер пола в юнитах (worldSize, не worldHalf). */
getWorldSize() { return this._worldHalf * 2; }
/** Включить/выключить пол (визуально и физически). */
setFloorEnabled(enabled) {
this._floorEnabled = !!enabled;
if (!this.scene) return;
const ground = this.scene.getMeshByName('editorGround');
if (ground) ground.setEnabled(this._floorEnabled);
// Линии осей тоже
if (Array.isArray(this._gridLines)) {
for (const line of this._gridLines) {
if (line && line.setEnabled) line.setEnabled(this._floorEnabled);
}
}
// Физика: отключаем коллизию с baseplate, чтобы игрок проваливался
if (this.physics) this.physics.floorEnabled = this._floorEnabled;
}
isFloorEnabled() { return this._floorEnabled !== false; }
/**
* Очистить гладкий ландшафт (RobloxTerrain) — убирает все chunks,
* отвязывает от физики, возвращает baseplate-пол, ставит spawn по умолчанию.
* Вызывается из UI (кнопка «✖» в Генератор-панели).
*/
clearRobloxTerrain() {
let hadTerrain = false;
if (this._robloxTerrain) {
try { this._robloxTerrain.disposeAll(); hadTerrain = true; } catch (e) {}
// ВАЖНО: обнуляем ссылку, иначе __robloxTest при новой генерации
// решит что terrain уже есть и НЕ переподключит его к physics.
this._robloxTerrain = null;
}
// Декорации тоже чистим (thin-instances очищаются, prototype остаётся
// в памяти для следующего применения).
if (this._smoothDecoManager) {
try { this._smoothDecoManager.clear(); } catch (e) {}
}
this._smoothDecoParams = null;
if (this.physics?.setRobloxTerrain) {
this.physics.setRobloxTerrain(null);
}
if (this.physics?.setSmoothDecoTrees) {
this.physics.setSmoothDecoTrees(null);
}
try { this.setFloorEnabled(true); } catch (e) {}
this._spawnPoint = { x: 0, y: 5, z: 0 };
try { this._updateSpawnMarker?.(); } catch (e) {}
// Прячем мини-карту гладкого ландшафта — grid больше нет.
if (window.__robloxMinimapGrid) {
window.__robloxMinimapGrid = null;
this._terrainStreamingEnabled = false;
}
// Помечаем dirty чтобы автосейв записал пустой robloxTerrain
try { this._onSceneChange?.(); } catch (e) {}
console.log(`[BabylonScene] clearRobloxTerrain (hadTerrain=${hadTerrain})`);
}
/**
* Множитель силы прыжка игрока. 1 = базовый (~8 у/с), 1.5 = в 1.5 раза выше.
* Применяется при enterPlayMode и через player.setJumpPower.
*/
setPlayerJumpPower(mul) {
const m = Math.max(0.2, Math.min(5, Number(mul) || 1));
this._jumpPowerMul = m;
if (this.player) this.player._jumpPowerMul = m;
}
getPlayerJumpPower() { return this._jumpPowerMul ?? 1; }
/** Тип прицела в Play: 'none' | 'dot' | 'cross' | 'circle'. */
setCrosshair(type) {
const allowed = ['none', 'dot', 'cross', 'circle'];
if (!allowed.includes(type)) return;
this._crosshair = type;
}
getCrosshair() { return this._crosshair || 'none'; }
/**
* LOD/culling для моделей: модели дальше LOD_FREEZE замораживают мировую
* матрицу (экономия CPU), модели дальше LOD_CULL — отключаются от рендера.
* Запускается в render-loop из tick().
*/
_updateModelLOD() {
if (!this.modelManager || !this.camera) return;
const cam = this.camera.position;
const LOD_FREEZE = 60; // юниты — за этим расстоянием freezeWorldMatrix
const LOD_FREEZE_SQ = LOD_FREEZE * LOD_FREEZE;
const LOD_CULL = 600; // юниты — за этим расстоянием полностью скрываем
// (было 200; увеличено чтобы модели на удалённых уровнях не пропадали при editor-камере)
const LOD_CULL_SQ = LOD_CULL * LOD_CULL;
for (const data of this.modelManager.instances.values()) {
const root = data.rootMesh;
if (!root) continue;
// Динамические объекты (зомби, спавнеры, runtime-спавнутые) НЕ
// подвергаем LOD-freeze — за ними двигает свой менеджер.
const gp = data.gameplay;
if (gp?.isZombie || gp?.isZombieSpawner || data._spawnedAtRuntime) continue;
const dx = root.position.x - cam.x;
const dy = root.position.y - cam.y;
const dz = root.position.z - cam.z;
const distSq = dx * dx + dy * dy + dz * dz;
// Cull
const shouldCull = distSq > LOD_CULL_SQ && data.visible !== false;
if (shouldCull && root._kubikonCulled !== true) {
root.setEnabled(false);
root._kubikonCulled = true;
} else if (!shouldCull && root._kubikonCulled === true) {
root.setEnabled(data.visible !== false);
root._kubikonCulled = false;
}
// Freeze
const shouldFreeze = distSq > LOD_FREEZE_SQ;
if (shouldFreeze && root._kubikonFrozen !== true) {
try { root.freezeWorldMatrix(); } catch (e) { /* ignore */ }
root._kubikonFrozen = true;
} else if (!shouldFreeze && root._kubikonFrozen === true) {
try { root.unfreezeWorldMatrix(); } catch (e) { /* ignore */ }
root._kubikonFrozen = false;
}
}
}
/**
* LOD-cull для примитивов: далёкие декорации скрываем, ближние видны.
* Только в Play-режиме (в редакторе пользователь должен видеть всю сцену
* чтобы редактировать). На больших проектах (Only Up: 568 примитивов на
* вертикальной башне) это критично — без LOD при повороте камеры Babylon
* frustum-cull'ит сотни мешей и FPS падает в пол.
*/
_updatePrimitiveLOD() {
if (!this._isPlaying) return;
if (!this.primitiveManager || !this.camera) return;
const cam = this.camera.position;
const CULL = 120;
const CULL_SQ = CULL * CULL;
for (const data of this.primitiveManager.instances.values()) {
const m = data.mesh;
if (!m) continue;
// Не трогаем явно скрытые/невидимые скриптом
if (data.visible === false) continue;
const dx = data.x - cam.x;
const dy = data.y - cam.y;
const dz = data.z - cam.z;
const distSq = dx * dx + dy * dy + dz * dz;
const shouldCull = distSq > CULL_SQ;
if (shouldCull && m._kubikonPrimCulled !== true) {
m.setEnabled(false);
m._kubikonPrimCulled = true;
} else if (!shouldCull && m._kubikonPrimCulled === true) {
m.setEnabled(true);
m._kubikonPrimCulled = false;
}
}
}
/**
* Roblox-style input handlers.
* Мышиные события — на canvas (только когда мышь над сценой).
* Клавиатурные — на window (работают при любом фокусе, как в реальных
* 3D-редакторах). Используем e.code (KeyW, KeyA, KeyS, KeyD, KeyQ, KeyE, KeyF)
* чтобы клавиши работали на любой раскладке (русская/английская).
*/
_setupInputControls() {
const canvas = this.canvas;
// === МЫШЬ ===
// mousedown на canvas в capture-фазе → срабатывает первым,
// даже если поверх есть другие listeners.
const onMouseDown = (e) => {
if (this._isPlaying) {
// В Play-режиме ЛКМ — клик игрока в forward-направлении.
// Pointer Lock — курсор всё равно в центре экрана.
if (e.button === 0) this._handlePlayClick();
return;
}
// Обновляем pointer координаты для raycast и Gizmo
const r = canvas.getBoundingClientRect();
this.scene.pointerX = e.clientX - r.left;
this.scene.pointerY = e.clientY - r.top;
// Если это ЛКМ — пробуем pickнуть гизмо. Если попали в гизмо —
// отдаём событие Babylon GizmoManager и выходим (не ставим блок).
// Проверка attachedMesh || attachedNode — у разных версий Babylon
// и при attachToMesh vs attachToNode заполняется разное поле.
const hasAttachment = this._gizmo &&
(this._gizmo.manager.attachedMesh || this._gizmo.manager.attachedNode);
if (e.button === 0 && hasAttachment) {
const ulScene = this._gizmoLayer?.utilityLayerScene;
if (ulScene) {
const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY);
if (ulPick && ulPick.hit) {
// Симулируем pointer-events для GizmoManager на utility-scene
ulScene.simulatePointerDown(ulPick);
this._gizmoDragging = true;
e.preventDefault();
return;
}
}
}
// Запоминаем стартовую точку любого нажатия — для drag-detection.
this._mouseDownButton = e.button;
this._mouseDownX = e.clientX;
this._mouseDownY = e.clientY;
this._mouseDownTime = Date.now();
// ЛКМ + tool=block/erase → активируем drag-постановку.
// Сразу же ставим первый блок в клетке под курсором.
if (e.button === 0 && !e.shiftKey
&& (this._activeTool === 'block' || this._activeTool === 'erase')) {
this._isDragPlacing = true;
this._lastPlacedKey = null;
this._dragLockAxis = null;
this._dragPlaceTick(false, /*isFirst*/ true);
e.preventDefault();
}
// ЛКМ + tool=terrain → активируем drag-кисть террейна.
// Shift модификатор обрабатывается внутри _terrainBrushTick (стирание).
else if (e.button === 0 && this._activeTool === 'terrain') {
this._isTerrainBrushing = true;
this._terrainDragLockY = null;
this._terrainHistoryOpen(); // снапшот для undo
this._setTerrainBrushPreviewActive(true);
this._terrainBrushTick(e.shiftKey, /*isFirst*/ true);
e.preventDefault();
}
// Shift+ЛКМ — drag-удаление (даже если tool=block)
else if (e.button === 0 && e.shiftKey) {
this._isDragPlacing = true;
this._lastPlacedKey = null;
this._dragLockAxis = null;
this._dragPlaceTick(true, /*isFirst*/ true);
e.preventDefault();
}
if (e.button === 2) {
this._isRotating = true;
this._lastMouseX = e.clientX;
this._lastMouseY = e.clientY;
canvas.style.cursor = 'grabbing';
e.preventDefault();
e.stopPropagation();
} else if (e.button === 1) {
this._isPanning = true;
this._lastMouseX = e.clientX;
this._lastMouseY = e.clientY;
canvas.style.cursor = 'move';
e.preventDefault();
e.stopPropagation();
}
// ЛКМ (button === 0) ничего не запускает сразу — обрабатывается на mouseup
// только если был "клик" (не drag).
};
// mouseup и mousemove — на window, чтобы drag работал даже когда
// курсор вышел за пределы canvas (стандартное поведение для drag).
const onMouseMove = (e) => {
// Babylon без detachControl сам не пишет в scene.pointerX/Y —
// делаем это руками. Нужны для raycast (scene.pick) и для гизмо.
const r = canvas.getBoundingClientRect();
this.scene.pointerX = e.clientX - r.left;
this.scene.pointerY = e.clientY - r.top;
// Если идёт drag гизмо — проксируем move в utility-scene
if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) {
const ulScene = this._gizmoLayer.utilityLayerScene;
const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY);
ulScene.simulatePointerMove(ulPick);
return;
}
// Если идёт drag-постановка блоков — пытаемся поставить в новой клетке
if (this._isDragPlacing) {
this._dragPlaceTick(e.shiftKey);
return;
}
// Если идёт drag-кисть террейна — продолжаем рисовать.
// Двигаем preview-меш под курсор ВНУТРИ drag тоже, иначе сфера
// зависает в точке первого клика, пока юзер водит мышью.
if (this._isTerrainBrushing) {
this._terrainBrushTick(e.shiftKey, /*isFirst*/ false);
this._updateTerrainBrushPosition();
return;
}
// Когда tool=terrain без drag'а — подвигаем preview-меш под курсор
if (this._activeTool === 'terrain') {
this._updateTerrainBrushPosition();
}
if (!this._isRotating && !this._isPanning) return;
const dx = e.clientX - this._lastMouseX;
const dy = e.clientY - this._lastMouseY;
this._lastMouseX = e.clientX;
this._lastMouseY = e.clientY;
if (this._isRotating) {
this.camera.rotation.y += dx * this.ROTATE_SENSITIVITY;
this.camera.rotation.x += dy * this.ROTATE_SENSITIVITY;
const limit = Math.PI / 2 - 0.05;
if (this.camera.rotation.x > limit) this.camera.rotation.x = limit;
if (this.camera.rotation.x < -limit) this.camera.rotation.x = -limit;
} else if (this._isPanning) {
const right = this._getCameraRight();
const up = this._getCameraUp();
this.camera.position.addInPlace(right.scale(-dx * this.PAN_SENSITIVITY));
this.camera.position.addInPlace(up.scale(dy * this.PAN_SENSITIVITY));
}
};
const onMouseUp = (e) => {
// Если идёт drag гизмо — отдаём pointerup и завершаем
if (this._gizmoDragging && this._gizmoLayer?.utilityLayerScene) {
const ulScene = this._gizmoLayer.utilityLayerScene;
const r = canvas.getBoundingClientRect();
this.scene.pointerX = e.clientX - r.left;
this.scene.pointerY = e.clientY - r.top;
const ulPick = ulScene.pick(this.scene.pointerX, this.scene.pointerY);
ulScene.simulatePointerUp(ulPick);
this._gizmoDragging = false;
this._mouseDownButton = -1;
return;
}
// Если был drag-кисть террейна — сбрасываем флаг
if (this._isTerrainBrushing) {
this._isTerrainBrushing = false;
this._terrainDragLockY = null;
this._smoothBrushLockY = null;
this._smoothBrushLastPos = null;
this._terrainHistoryClose(); // фиксируем undo-снапшот
this._setTerrainBrushPreviewActive(false);
this._mouseDownButton = -1;
return;
}
// Если был drag-place — просто сбрасываем флаг, клик не обрабатываем
// (первая постановка уже сделана при mousedown).
if (this._isDragPlacing) {
this._isDragPlacing = false;
this._lastPlacedKey = null;
this._dragLockAxis = null;
this._mouseDownButton = -1;
return;
}
// Если это была ЛКМ и НЕ drag (курсор не сдвинулся существенно)
// → это клик; обрабатываем как редактор-клик (поставить/удалить блок).
if (e.button === 0 && this._mouseDownButton === 0) {
const dx = Math.abs(e.clientX - this._mouseDownX);
const dy = Math.abs(e.clientY - this._mouseDownY);
const dt = Date.now() - this._mouseDownTime;
if (dx < 4 && dy < 4 && dt < 400) {
this._handleEditorClick(e.shiftKey, e.ctrlKey || e.metaKey);
}
}
this._mouseDownButton = -1;
if (e.button === 2) {
this._isRotating = false;
canvas.style.cursor = 'default';
} else if (e.button === 1) {
this._isPanning = false;
canvas.style.cursor = 'default';
}
};
const onWheel = (e) => {
e.preventDefault();
const forward = this._getCameraForward();
const delta = -Math.sign(e.deltaY) * this.ZOOM_SPEED;
this.camera.position.addInPlace(forward.scale(delta));
};
const onContextMenu = (e) => {
e.preventDefault();
};
// === КЛАВИАТУРА ===
// Используем e.code (KeyW, KeyA, ...) — независимо от раскладки.
// ВАЖНО: e.key на русской раскладке возвращает кириллицу ('ц', 'ы', ...),
// поэтому надёжно использовать только e.code.
/**
* Игнорировать события клавиатуры если фокус в input/textarea/contenteditable.
* Иначе пробел/буквы из ввода в модалке двигают камеру и блокируют ввод.
* Также игнорируем когда открыта модалка (z-index overlay).
*/
const _isInEditableEl = (el) => {
if (!el) return false;
const tag = (el.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
if (el.isContentEditable) return true;
// Monaco-редактор скриптов — ввод идёт через скрытую textarea.inputarea
// внутри .monaco-editor. Принадлежность дереву Monaco = идёт набор.
if (typeof el.closest === 'function' && el.closest('.monaco-editor')) return true;
return false;
};
const isTypingTarget = (target) => {
// Проверяем И e.target, И document.activeElement. Ctrl+V в Monaco
// иногда всплывает до window с target == document.body (фокус живёт
// на скрытой inputarea), поэтому одной проверки target мало — без
// activeElement глобальный Ctrl+V (pasteFromClipboard) перехватывал
// вставку кода в редактор скриптов.
return _isInEditableEl(target) || _isInEditableEl(document.activeElement);
};
const onKeyDown = (e) => {
if (isTypingTarget(e.target)) return;
this._codes.add(e.code);
if (e.shiftKey) this._shiftDown = true;
// Маршрутизация game.onKey в Play-режиме
if (this._isPlaying && this.gameRuntime) {
const key = this._normalizeKey(e);
this.gameRuntime.routeGlobalEvent('keydown', { key, code: e.code });
}
if (e.code === 'KeyF') {
this._focusOnTarget(new Vector3(0, 0, 0));
}
// Ctrl+D — дублировать выделенное
if (e.code === 'KeyD' && (e.ctrlKey || e.metaKey) && !this._isPlaying) {
e.preventDefault();
this.duplicateSelected();
return;
}
// Ctrl+C — копировать выделенное в буфер (Фаза 5.10).
if (e.code === 'KeyC' && (e.ctrlKey || e.metaKey) && !this._isPlaying) {
e.preventDefault();
this.copySelected();
return;
}
// Ctrl+V — вставить из буфера (работает и между проектами).
if (e.code === 'KeyV' && (e.ctrlKey || e.metaKey) && !this._isPlaying) {
e.preventDefault();
this.pasteFromClipboard();
return;
}
// Ctrl+Z — undo, Ctrl+Shift+Z или Ctrl+Y — redo
if ((e.ctrlKey || e.metaKey) && !this._isPlaying) {
if (e.code === 'KeyZ' && !e.shiftKey) {
e.preventDefault();
this.undo?.();
return;
}
if ((e.code === 'KeyZ' && e.shiftKey) || e.code === 'KeyY') {
e.preventDefault();
this.redo?.();
return;
}
// Ctrl+G — сгруппировать выделенное в новую папку
if (e.code === 'KeyG') {
e.preventDefault();
this.groupSelected();
return;
}
// Ctrl+A — выделить всё
if (e.code === 'KeyA') {
e.preventDefault();
this.selection?.selectAll();
return;
}
}
// R — повернуть ghost-модель на 90° (или выделенную модель)
if (e.code === 'KeyR' && !this._isPlaying) {
const sel = this.selection?.getSelection();
if (sel?.type === 'model') {
const newAngle = (sel.rotationY || 0) + Math.PI / 2;
this.selection.rotateSelectedModel(newAngle);
} else if (this._activeTool === 'model') {
this._ghostRotationY = (this._ghostRotationY + Math.PI / 2) % (Math.PI * 2);
}
}
// Delete / Backspace — удалить выделенный
if ((e.code === 'Delete' || e.code === 'Backspace') && !this._isPlaying) {
// Приоритет: выбранная инструментом «Выбрать деко» декорация.
if (this._decoSelection) {
this._deleteSelectedDeco();
e.preventDefault();
} else if (this.selection?.getSelection()) {
this.selection.deleteSelected();
e.preventDefault();
}
}
// Escape — снять выделение + переключиться на инструмент «Выделить»
// (в режиме игры Esc обрабатывает PlayerController — выход из Play).
if (e.code === 'Escape' && !this._isPlaying) {
this.selection?.clear();
if (this._onEditorEscape) {
try { this._onEditorEscape(); } catch (err) { /* ignore */ }
}
}
if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) {
e.preventDefault();
}
};
const onKeyUp = (e) => {
if (isTypingTarget(e.target)) return;
this._codes.delete(e.code);
if (!e.shiftKey) this._shiftDown = false;
if (this._isPlaying && this.gameRuntime) {
const key = this._normalizeKey(e);
this.gameRuntime.routeGlobalEvent('keyup', { key, code: e.code });
}
};
const onBlur = () => {
this._codes.clear();
this._shiftDown = false;
this._isRotating = false;
this._isPanning = false;
canvas.style.cursor = 'default';
};
// Регистрация:
// - mousedown/move/up на CANVAS в capture-фазе. Это самое надёжное место
// для перехвата мыши над сценой; наш обработчик отрабатывает первым,
// до Babylon-овских стандартных listeners.
// - keydown/keyup — на window (клавиатуру всегда слушаем глобально).
// - wheel/contextmenu — на canvas в capture.
canvas.addEventListener('mousedown', onMouseDown, true);
canvas.addEventListener('wheel', onWheel, { passive: false, capture: true });
canvas.addEventListener('contextmenu', onContextMenu, true);
// mousemove/mouseup на window — для drag за пределами canvas.
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
this._listeners = [
{ target: canvas, type: 'mousedown', fn: onMouseDown, opts: true },
{ target: canvas, type: 'wheel', fn: onWheel, opts: { capture: true } },
{ target: canvas, type: 'contextmenu', fn: onContextMenu, opts: true },
{ target: window, type: 'mousemove', fn: onMouseMove },
{ target: window, type: 'mouseup', fn: onMouseUp },
{ target: window, type: 'keydown', fn: onKeyDown },
{ target: window, type: 'keyup', fn: onKeyUp },
{ target: window, type: 'blur', fn: onBlur },
];
}
/**
* Двигаем камеру по WASDQE — работает всегда, не требует зажатой ПКМ
* (Minecraft Creative-style — удобнее чем Roblox для редактирования сцены).
* ПКМ нужна только для поворота камеры.
* Вызывается каждый кадр из render loop.
* Используем e.code — независимо от раскладки клавиатуры.
*/
_updateCameraMovement() {
if (this._isPlaying) return; // в режиме игры редактор-камера не движется
const c = this._codes;
if (c.size === 0) return;
const dt = this.engine.getDeltaTime() / 1000;
const speed = this.MOVE_SPEED * dt * (this._shiftDown ? this.SHIFT_MULTIPLIER : 1);
const forward = this._getCameraForward();
const right = this._getCameraRight();
const worldUp = new Vector3(0, 1, 0);
const move = Vector3.Zero();
if (c.has('KeyW') || c.has('ArrowUp')) move.addInPlace(forward.scale(speed));
if (c.has('KeyS') || c.has('ArrowDown')) move.addInPlace(forward.scale(-speed));
if (c.has('KeyD') || c.has('ArrowRight')) move.addInPlace(right.scale(speed));
if (c.has('KeyA') || c.has('ArrowLeft')) move.addInPlace(right.scale(-speed));
if (c.has('KeyE') || c.has('Space')) move.addInPlace(worldUp.scale(speed));
if (c.has('KeyQ')) move.addInPlace(worldUp.scale(-speed));
if (move.lengthSquared() > 0) {
this.camera.position.addInPlace(move);
}
}
/**
* Единичный вектор «вперёд» камеры (с учётом её поворота).
*/
_getCameraForward() {
const yaw = this.camera.rotation.y;
const pitch = this.camera.rotation.x;
return new Vector3(
Math.sin(yaw) * Math.cos(pitch),
-Math.sin(pitch),
Math.cos(yaw) * Math.cos(pitch)
).normalize();
}
/**
* Единичный вектор «вправо» камеры (перпендикуляр forward в горизонтальной плоскости).
*/
_getCameraRight() {
const yaw = this.camera.rotation.y;
return new Vector3(Math.cos(yaw), 0, -Math.sin(yaw)).normalize();
}
/**
* Единичный вектор «вверх» относительно камеры.
*/
_getCameraUp() {
const forward = this._getCameraForward();
const right = this._getCameraRight();
return Vector3.Cross(right, forward).normalize();
}
/**
* Фокус на точке: ставим камеру в 15 единицах от target по текущему направлению.
* Будет использоваться для F — focus on selected.
*/
_focusOnTarget(target) {
const offset = this._getCameraForward().scale(-15);
this.camera.position = target.add(offset);
this.camera.setTarget(target);
}
// === ИНСТРУМЕНТЫ И БЛОКИ ===========================================
/**
* Создать "призрачный" блок — полупрозрачный преview, показывает где
* появится блок при клике.
*/
_createGhostBlock() {
const ghost = MeshBuilder.CreateBox('ghostBlock', { size: 1.02 }, this.scene);
const mat = new StandardMaterial('ghostMat', this.scene);
mat.diffuseColor = new Color3(0.4, 0.9, 0.4);
mat.alpha = 0.35;
mat.specularColor = new Color3(0, 0, 0);
mat.disableLighting = true;
ghost.material = mat;
ghost.isPickable = false; // raycast его игнорирует
ghost.setEnabled(false);
this._ghostMesh = ghost;
}
/**
* Создать видимый маркер точки спавна — полупрозрачный жёлтый цилиндр со
* светящейся вершиной. Виден только в редакторе, скрывается в Play.
*/
_createSpawnMarker() {
// Базовый цилиндр-подставка
const base = MeshBuilder.CreateCylinder(
'spawnMarkerBase',
{ diameterTop: 1.0, diameterBottom: 1.2, height: 0.15, tessellation: 24 },
this.scene
);
const baseMat = new StandardMaterial('spawnBaseMat', this.scene);
baseMat.diffuseColor = new Color3(0.95, 0.75, 0.2);
baseMat.emissiveColor = new Color3(0.3, 0.2, 0);
baseMat.specularColor = new Color3(0, 0, 0);
baseMat.alpha = 0.85;
base.material = baseMat;
// Внутренний светящийся столб
const beam = MeshBuilder.CreateCylinder(
'spawnMarkerBeam',
{ diameter: 0.4, height: 2.5, tessellation: 16 },
this.scene
);
const beamMat = new StandardMaterial('spawnBeamMat', this.scene);
beamMat.diffuseColor = new Color3(1, 0.9, 0.3);
beamMat.emissiveColor = new Color3(1, 0.85, 0.2);
beamMat.specularColor = new Color3(0, 0, 0);
beamMat.alpha = 0.4;
beamMat.disableLighting = true;
beam.material = beamMat;
beam.position.y = 1.3;
// Группируем base+beam в TransformNode чтобы двигать как одно
const root = new TransformNode('spawnMarker', this.scene);
base.parent = root;
beam.parent = root;
root.position = new Vector3(this._spawnPoint.x, this._spawnPoint.y, this._spawnPoint.z);
// Делаем маркер pickable, чтобы можно было кликнуть и выделить.
// Метаданные для SelectionManager: { isSpawn: true }.
base.isPickable = true;
beam.isPickable = true;
base.metadata = { isSpawn: true };
beam.metadata = { isSpawn: true };
this._spawnMarker = root;
this._spawnMarkerMeshes = [base, beam];
}
/** Обновить позицию визуального маркера спавна. */
_updateSpawnMarker() {
if (!this._spawnMarker) return;
this._spawnMarker.position.set(
this._spawnPoint.x,
this._spawnPoint.y,
this._spawnPoint.z
);
}
/** Скрыть/показать маркер спавна. */
_setSpawnMarkerVisible(visible) {
if (!this._spawnMarker) return;
this._spawnMarker.setEnabled(visible);
// КРИТИЧНО: при скрытии маркера в Play также делаем его непикаемым.
// Babylon `pickWithRay` ловит меши даже при `setEnabled(false)` если
// disabled у parent TransformNode. Без isPickable=false луч стрельбы
// попадает в столб маркера в 5м перед игроком.
if (this._spawnMarkerMeshes) {
for (const m of this._spawnMarkerMeshes) {
if (m) m.isPickable = visible;
}
}
}
/**
* Raycast от курсора в сцену.
* Возвращает { mesh, point, normal } либо null если ни во что не попали.
* Игнорирует ghost-блок и линии сетки.
*/
/**
* Нормализация клавиши из KeyboardEvent в простую строку для game.onKey.
* KeyW → 'w', Space → 'space', ArrowUp → 'arrowup', ShiftLeft → 'shift', ...
*/
_normalizeKey(e) {
const code = e.code || '';
// Буквы KeyA..KeyZ → 'a'..'z'
if (/^Key[A-Z]$/.test(code)) return code.charAt(3).toLowerCase();
// Цифры Digit0..Digit9 → '0'..'9'
if (/^Digit\d$/.test(code)) return code.charAt(5);
// Спецклавиши
const map = {
Space: 'space',
Enter: 'enter',
NumpadEnter: 'enter',
Escape: 'escape',
Tab: 'tab',
Backspace: 'backspace',
ShiftLeft: 'shift', ShiftRight: 'shift',
ControlLeft: 'ctrl', ControlRight: 'ctrl',
AltLeft: 'alt', AltRight: 'alt',
ArrowUp: 'arrowup', ArrowDown: 'arrowdown',
ArrowLeft: 'arrowleft', ArrowRight: 'arrowright',
};
if (map[code]) return map[code];
// Fallback — сам key в lower-case
return String(e.key || code).toLowerCase();
}
/**
* Pick по центру экрана (для Play-режима где курсор залочен).
* Используется для game.self.onClick — клик луч-форвард игрока.
*/
_pickFromCenter() {
const w = this.engine?.getRenderWidth?.() || this.canvas.width;
const h = this.engine?.getRenderHeight?.() || this.canvas.height;
const pi = this.scene.pick(w / 2, h / 2, (mesh) => {
if (!mesh.isPickable) return false;
if (mesh === this._ghostMesh) return false;
if (mesh.name && mesh.name.startsWith('gridLine')) return false;
return true;
});
if (!pi || !pi.hit) return null;
let mesh = pi.pickedMesh;
if (mesh?.metadata?._isBlockProto && this.blockManager) {
const proxy = this.blockManager.findProxyByPickInfo(pi);
if (proxy) mesh = proxy;
}
return { mesh, point: pi.pickedPoint, pickInfo: pi };
}
/**
* Извлечь target {kind, ref} из mesh (proxy/прим/модель).
* Используется при клике/touch в Play.
*/
_meshToTarget(mesh) {
if (!mesh || !mesh.metadata) return null;
const md = mesh.metadata;
if (md.isBlock) {
return { kind: 'block', ref: { x: md.gridX, y: md.gridY, z: md.gridZ } };
}
if (md.isModel) return { kind: 'model', id: md.instanceId };
if (md.isPrimitive) return { kind: 'primitive', id: md.primitiveId };
return null;
}
/**
* Детекция касания игроком объектов с target-скриптами.
* Для каждого target-скрипта проверяем AABB-overlap с игроком.
* Событие 'touch' эмитится один раз на «вход» (на rising edge) — пока
* игрок не выйдет из объекта и не вернётся, повторно touch не вызывается.
*/
_detectTouchEvents() {
const rt = this.gameRuntime;
if (!rt || !this.player?._pos) return;
const scripts = this._scripts || [];
if (scripts.length === 0) return;
// Кэш «контакта»: scriptId → true если сейчас касается
if (!this._touchState) this._touchState = new Map();
const seen = new Set();
const px = this.player._pos.x;
const py = this.player._pos.y; // центр капсулы
const pz = this.player._pos.z;
const phw = this.player.HALF_W ?? 0.3;
const phh = this.player.HALF_H ?? 0.9;
const phd = this.player.HALF_D ?? 0.3;
// EPS — допуск касания. Когда игрок СТОИТ на объекте сверху,
// низ его капсулы строго совпадает с верхом объекта (зазор 0),
// и строгое сравнение AABB даёт «не пересекаются». Расширяем
// зону на EPS, чтобы «стоит на объекте/вплотную» = касание.
// Без этого onTouch финиша/плитки не срабатывает (игрок встал).
const EPS = 0.25;
// 1) Касания объектов с target-скриптами (ключ touchState = 's:'+scriptId)
for (const s of scripts) {
if (!s.target) continue;
const key = 's:' + s.id;
seen.add(key);
const aabb = this._targetAABB(s.target);
if (!aabb) continue;
const overlap =
px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS &&
py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS &&
pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS;
const wasTouching = this._touchState.get(key);
if (overlap && !wasTouching) {
this._touchState.set(key, true);
rt.routeEvent(s.target, 'touch', {});
rt.routeGlobalEvent('playerTouch', { target: s.target });
} else if (!overlap && wasTouching) {
this._touchState.set(key, false);
rt.routeEvent(s.target, 'untouch', {});
}
}
// 2) Касания примитивов-триггеров (type === 'trigger') БЕЗ скрипта —
// шлём глобальное playerTouch с target. Это позволяет писать
// логику чек-поинтов в одном глобальном скрипте без скриптов на каждом
// триггере. Ключ: 'p:'+id, чтобы не пересекаться со скриптами.
// Сюда же — примитивы, заспавненные скриптом (data._scriptSpawned):
// для них тоже шлём playerTouch, чтобы игры «поймай объект»
// могли ловить падающие кубы через game.onPlayerTouch.
const prims = this.primitiveManager?.instances;
if (prims && prims.size > 0) {
for (const data of prims.values()) {
const isTrigger = data?.type === 'trigger';
const isSpawned = data?._scriptSpawned === true;
if (!isTrigger && !isSpawned) continue;
const id = data.id;
// Если на этот примитив УЖЕ повешен target-скрипт — он
// обработан в блоке выше, чтобы не дублировать события.
const hasScript = scripts.some(s =>
s.target?.kind === 'primitive' && (s.target.id ?? s.target.ref) === id
);
if (hasScript) continue;
const key = 'p:' + id;
seen.add(key);
const hx = data.sx / 2, hy = data.sy / 2, hz = data.sz / 2;
const overlap =
px + phw > data.x - hx - EPS && px - phw < data.x + hx + EPS &&
py + phh > data.y - hy - EPS && py - phh < data.y + hy + EPS &&
pz + phd > data.z - hz - EPS && pz - phd < data.z + hz + EPS;
const wasTouching = this._touchState.get(key);
if (overlap && !wasTouching) {
this._touchState.set(key, true);
// target — строка-ref 'primitive:<id>': её можно
// передать в game.scene.delete и сравнивать.
rt.routeGlobalEvent('playerTouch', {
target: 'primitive:' + id,
});
} else if (!overlap && wasTouching) {
this._touchState.set(key, false);
}
}
}
// 3) Касания объектов, на которые подписан ГЛОБАЛЬНЫЙ скрипт через
// findOne(x).onTouch(...) (rt._watchedTouchRefs). Это объекты без
// собственного скрипта-target и не триггеры — например цели туториала.
// Ключ touchState = 'w:'+ref, событие — адресное (routeInstEvent по ref).
const watched = rt._watchedTouchRefs;
if (watched && watched.size > 0) {
for (const ref of watched) {
const target = this._refToTarget(ref);
if (!target) continue;
// Не дублируем, если на этот же объект уже висит target-скрипт
// (он обработан в блоке 1 и сам отправит touch своему скрипту —
// но адресное instTouch всё равно нужно глобальному подписчику,
// поэтому НЕ пропускаем, просто используем отдельный ключ 'w:').
const aabb = this._targetAABB(target);
if (!aabb) continue;
const key = 'w:' + ref;
seen.add(key);
const overlap =
px + phw > aabb.minX - EPS && px - phw < aabb.maxX + EPS &&
py + phh > aabb.minY - EPS && py - phh < aabb.maxY + EPS &&
pz + phd > aabb.minZ - EPS && pz - phd < aabb.maxZ + EPS;
const wasTouching = this._touchState.get(key);
if (overlap && !wasTouching) {
this._touchState.set(key, true);
rt.routeInstEvent(ref, 'instTouch', {});
} else if (!overlap && wasTouching) {
this._touchState.set(key, false);
rt.routeInstEvent(ref, 'instUntouch', {});
}
}
}
// Чистим устаревшие записи (удалённые скрипты/триггеры)
for (const id of this._touchState.keys()) {
if (!seen.has(id)) this._touchState.delete(id);
}
}
/** Ref-строка 'primitive:NN' | 'model:NN' → {kind,id} для _targetAABB. */
_refToTarget(ref) {
if (typeof ref !== 'string') return null;
const colon = ref.indexOf(':');
if (colon < 0) return null;
const kind = ref.slice(0, colon);
const rest = ref.slice(colon + 1);
if (kind === 'primitive') {
// через runtime — корректно разрешает и local-ref, и числовой id
const id = this.gameRuntime?._resolvePrimitiveId
? this.gameRuntime._resolvePrimitiveId(rest)
: (Number.isFinite(Number(rest)) ? Number(rest) : rest);
return { kind: 'primitive', id };
}
if (kind === 'model') {
const n = Number(rest);
return { kind: 'model', id: Number.isFinite(n) ? n : rest };
}
return null;
}
/** Получить мировой AABB target-объекта (для touch-детекции). */
_targetAABB(target) {
if (!target) return null;
try {
if (target.kind === 'block') {
const r = target.ref || target;
return {
minX: r.x - 0.5, maxX: r.x + 0.5,
minY: r.y, maxY: r.y + 1,
minZ: r.z - 0.5, maxZ: r.z + 0.5,
};
}
if (target.kind === 'model') {
const id = target.id ?? target.ref;
return this.modelManager?.getInstanceAABB?.(id) || null;
}
if (target.kind === 'primitive') {
const id = target.id ?? target.ref;
const data = this.primitiveManager?.instances?.get(id);
if (!data) return null;
const hx = data.sx / 2, hy = data.sy / 2, hz = data.sz / 2;
return {
minX: data.x - hx, maxX: data.x + hx,
minY: data.y - hy, maxY: data.y + hy,
minZ: data.z - hz, maxZ: data.z + hz,
};
}
} catch (e) { /* ignore */ }
return null;
}
/**
* Обработка клика в Play-режиме.
* Делает forward-pick и роутит click-событие:
* - в self-обработчики скриптов (routeEvent с target)
* - в глобальные обработчики (game.onClick) с event.target
*/
_handlePlayClick() {
if (!this._isPlaying) return;
// Мультиплеер-выстрел: если у сцены есть mpSync, шлём 'shoot' серверу.
// Используем forward-вектор игрока (XZ-плоскость) — куда смотрит,
// туда и выстрел. На сервере дальше идёт raycast по другим игрокам.
if (this._mpSync && this.player?._pos) {
try {
const yaw = this.player._yaw || 0;
// forward в плоскости XZ: yaw=0 — смотрим в +Z
const dirX = Math.sin(yaw);
const dirZ = Math.cos(yaw);
this._mpSync.sendShoot(
this.player._pos.x,
this.player._pos.z,
dirX, dirZ,
);
} catch (e) { /* room closed / mpSync disposed */ }
}
if (!this.gameRuntime) return;
const pick = this._pickFromCenter();
const target = pick?.mesh ? this._meshToTarget(pick.mesh) : null;
const point = pick?.point ? { x: pick.point.x, y: pick.point.y, z: pick.point.z } : null;
// 1) Self-onClick — только если target есть
if (target) {
this.gameRuntime.routeEvent(target, 'click', { point });
}
// 2) Глобальный onClick — всегда (даже если попали в пустоту)
this.gameRuntime.routeGlobalEvent('click', { point, target });
// 3) toolUse — если в активном слоте инвентаря есть инструмент/оружие.
// Для game.player.onToolUse(fn) из скриптов (Фаза 4.2).
// Phase 6.4: прокидываем customToolId если это кастомный tool из game.tools.create.
try {
const active = this.inventory?.getActive?.();
if (active) {
const tool = {
kind: active.kind,
modelTypeId: active.modelTypeId,
name: active.name,
};
if (active.params && active.params._customToolId) {
tool.customToolId = active.params._customToolId;
}
this.gameRuntime.routeGlobalEvent('toolUse', { tool, point, target });
}
} catch (e) { /* ignore */ }
}
/**
* Установить мультиплеер-синхронизатор. Вызывается из KubikonPlayer
* после joinOrCreate. При null — отключаем мультиплеер.
*/
/** Тач-режим — управление через виртуальный джойстик и тач-свайп камеры.
* Должно вызываться ДО enterPlayMode, иначе PlayerController создастся
* с дефолтным mouse/keyboard-управлением. */
setTouchMode(enabled) {
this._touchMode = !!enabled;
// Если уже в Play и есть player — пробрасываем
if (this.player && typeof this.player.setTouchMode === 'function') {
this.player.setTouchMode(this._touchMode);
}
// На тач-устройствах (= мобила/планшет) включаем low-perf-режим
// автоматически: уменьшаем разрешение рендера, отключаем тени,
// увеличиваем dt физики. Это даёт ×2-3 прирост FPS.
if (this._touchMode) this.applyLowPerfMode();
}
/**
* Включить «лёгкий» режим рендера для слабых устройств / мобилок.
* Можно вызвать вручную и на десктопе если FPS проседает.
*
* Главная идея: не пикселим картинку, а уменьшаем нагрузку безболезненно:
* 1. DPR-нормализация: рендерим в DPR=1 (а не ×2-3 как Retina по умолчанию).
* Это даёт х2-х9 буст FPS без видимой потери качества — глаз
* телефонного экрана не различает разницу между DPR=2 и DPR=1.
* В отличие от scalingLevel=2 (рендер в половину родного), здесь
* текстуры остаются чёткими — рендерим в реальное число css-пикселей.
* 2. Отключаем тени — ShadowGenerator самый дорогой эффект.
* 3. skipPointerMovePicking — не делаем raycast от мыши на каждый move.
* 4. maxZ=200 — урезаем дальность рендера.
*
* НЕ делаем (после теста — слишком ухудшало картинку и плавность):
* - hardwareScalingLevel > 1 (давало пикселизацию текстур)
* - FPS-cap 30 через frame-skip (давало дёрганье движения)
* - ZombieManager тик через кадр (тоже дёрганье)
*/
applyLowPerfMode() {
if (this._lowPerfApplied) return;
this._lowPerfApplied = true;
// НЕ трогаем hardwareScalingLevel — оставляем нативное разрешение
// экрана (включая DPR). На современных телефонах GPU справляется,
// а текстуры и текст ника остаются чёткими. Прирост FPS даём за
// счёт отключённых теней / AA / maxZ=200, а не уменьшения буфера.
// Тени — выключаем
try {
if (this._shadowGenerator) {
this._shadowGenerator.dispose();
this._shadowGenerator = null;
}
this._shadowQuality = 'off';
} catch (e) { /* ignore */ }
// Скип pointer-move picking — каждый кадр не делаем raycast от мыши
try { this.scene.skipPointerMovePicking = true; } catch (e) {}
// НЕ включаем blockMaterialDirtyMechanism — он ломает свойства
// материалов трейсеров/дебриса (создаются после старта, шейдер не
// пересчитывается, emissiveColor/alpha/disableLighting не применяются).
try {
if (this.scene) {
this.scene.autoClear = true;
this.scene.autoClearDepthAndStencil = true;
}
} catch (e) {}
// Уменьшаем дальность рендера если камера далеко смотрит
try {
if (this.camera && this.camera.maxZ) {
this.camera.maxZ = Math.min(this.camera.maxZ, 200);
}
} catch (e) {}
// FPS-cap НЕ ставим — лучше нативные 60 FPS если устройство тянет.
this._lowPerfFrameSkip = false;
// eslint-disable-next-line no-console
console.log('[BabylonScene] low-perf mode applied (DPR-normalized, no shadows, no frame-skip)');
}
setMultiplayerSync(sync) {
this._mpSync = sync;
// Сразу шлём текущее активное оружие, чтобы remote-клиенты
// увидели его в руке модели сразу после нашего onAdd.
if (sync) {
try {
const active = this.inventory?.getActive?.();
const modelId = (active && active.kind === 'weapon')
? (active.modelTypeId || '')
: '';
sync.sendWeapon(modelId);
} catch (e) { /* ignore */ }
}
}
_pickFromMouse() {
// 1) Стандартный pick — для моделей, примитивов, пола, ghost'ов и т.п.
// Блоки рисуются через thin-instances; их proto-меш ИГНОРИРУЕМ
// в этом проходе (он бы вернул thin instance с потерянным индексом
// в новых версиях Babylon — старая боль с выделением и постановкой).
const pi = this.scene.pick(
this.scene.pointerX,
this.scene.pointerY,
(mesh) => {
if (!mesh.isPickable) return false;
if (mesh === this._ghostMesh) return false;
if (mesh.name && mesh.name.startsWith('gridLine')) return false;
if (mesh.metadata?._isBlockProto) return false; // ⬅ важно!
return true;
}
);
// 2) Отдельный пик блоков через свой raycast по AABB-сетке.
// Гораздо надёжнее thin-instance pick'а: даём гарантированный
// proxy + нормаль грани попадания.
const blockHit = this._pickBlockManually();
// Выбираем ближайший: либо стандартный pick, либо блок.
if (pi && pi.hit && blockHit) {
// Сравниваем по дистанции от камеры
const cam = this.scene.activeCamera?.position;
if (cam) {
const d1Sq = (pi.pickedPoint.x - cam.x) ** 2
+ (pi.pickedPoint.y - cam.y) ** 2
+ (pi.pickedPoint.z - cam.z) ** 2;
const d2Sq = (blockHit.point.x - cam.x) ** 2
+ (blockHit.point.y - cam.y) ** 2
+ (blockHit.point.z - cam.z) ** 2;
if (d2Sq < d1Sq) {
return blockHit;
}
}
return {
mesh: pi.pickedMesh,
point: pi.pickedPoint,
normal: pi.getNormal(true),
pickInfo: pi,
};
}
if (blockHit) return blockHit;
if (pi && pi.hit) {
return {
mesh: pi.pickedMesh,
point: pi.pickedPoint,
normal: pi.getNormal(true),
pickInfo: pi,
};
}
return null;
}
/**
* Свой raycast по блокам. Идёт от камеры в направлении курсора, проходит
* по сетке и проверяет каждую клетку: есть ли блок в blockManager.blocks?
* Возвращает { mesh: proxy, point, normal } или null.
*
* Используется DDA (digital differential analyzer) — самый быстрый алгоритм
* для voxel-raycast.
*/
_pickBlockManually() {
if (!this.blockManager || !this.scene.activeCamera) return null;
// Получаем ray из курсора
const camera = this.scene.activeCamera;
const ray = this.scene.createPickingRay(
this.scene.pointerX, this.scene.pointerY, null, camera
);
const origin = ray.origin;
const dir = ray.direction;
// DDA для voxel-сетки.
// Стартуем с клетки в которой находится origin
let x = Math.round(origin.x);
let y = Math.floor(origin.y);
let z = Math.round(origin.z);
// Шаги по каждой оси
const stepX = dir.x > 0 ? 1 : -1;
const stepY = dir.y > 0 ? 1 : -1;
const stepZ = dir.z > 0 ? 1 : -1;
// Длина шага вдоль луча для перехода на следующую клетку
const tDeltaX = Math.abs(dir.x) > 1e-8 ? Math.abs(1 / dir.x) : Infinity;
const tDeltaY = Math.abs(dir.y) > 1e-8 ? Math.abs(1 / dir.y) : Infinity;
const tDeltaZ = Math.abs(dir.z) > 1e-8 ? Math.abs(1 / dir.z) : Infinity;
// Расстояние до первой границы клетки
// Блок (x,y,z) занимает X: x-0.5..x+0.5, Y: y..y+1, Z: z-0.5..z+0.5
const nextBoundaryX = x + 0.5 * stepX;
const nextBoundaryY = stepY > 0 ? (y + 1) : y;
const nextBoundaryZ = z + 0.5 * stepZ;
let tMaxX = Math.abs(dir.x) > 1e-8 ? (nextBoundaryX - origin.x) / dir.x : Infinity;
let tMaxY = Math.abs(dir.y) > 1e-8 ? (nextBoundaryY - origin.y) / dir.y : Infinity;
let tMaxZ = Math.abs(dir.z) > 1e-8 ? (nextBoundaryZ - origin.z) / dir.z : Infinity;
const MAX_STEPS = 200; // максимум 200 клеток по лучу
const MAX_DIST = 100; // и не дальше 100м
// Какая ось пересечена последней (для вычисления нормали)
let lastAxis = -1;
for (let i = 0; i < MAX_STEPS; i++) {
// Проверяем клетку (x, y, z)
if (y >= 0 && y < 200) {
const key = `${x},${y},${z}`;
const proxy = this.blockManager.blocks.get(key);
if (proxy && proxy.metadata?.canCollide !== false) {
// Нашли! Вычисляем точку контакта и нормаль.
let t;
let nx = 0, ny = 0, nz = 0;
if (lastAxis === 0) {
// Зашли через X-грань
t = tMaxX - tDeltaX;
nx = -stepX;
} else if (lastAxis === 1) {
t = tMaxY - tDeltaY;
ny = -stepY;
} else if (lastAxis === 2) {
t = tMaxZ - tDeltaZ;
nz = -stepZ;
} else {
// Стартуем уже внутри клетки — нормаль вверх по умолчанию
t = 0;
ny = 1;
}
if (t > MAX_DIST) return null;
const point = {
x: origin.x + dir.x * t,
y: origin.y + dir.y * t,
z: origin.z + dir.z * t,
};
return {
mesh: proxy,
point: { x: point.x, y: point.y, z: point.z, clone() { return { x: this.x, y: this.y, z: this.z }; } },
normal: { x: nx, y: ny, z: nz },
pickInfo: null,
};
}
}
// Шаг по ближайшей оси
if (tMaxX < tMaxY && tMaxX < tMaxZ) {
if (tMaxX > MAX_DIST) return null;
x += stepX;
tMaxX += tDeltaX;
lastAxis = 0;
} else if (tMaxY < tMaxZ) {
if (tMaxY > MAX_DIST) return null;
y += stepY;
tMaxY += tDeltaY;
lastAxis = 1;
} else {
if (tMaxZ > MAX_DIST) return null;
z += stepZ;
tMaxZ += tDeltaZ;
lastAxis = 2;
}
}
return null;
}
/**
* Обновить позицию ghost-блока под курсором.
* Вызывается каждый кадр когда tool='block'.
*/
_updateGhostPosition() {
if (!this._ghostMesh) return;
if (this._isPlaying) {
this._ghostMesh.setEnabled(false);
return;
}
if (this._activeTool !== 'block' && this._activeTool !== 'model') {
this._ghostMesh.setEnabled(false);
return;
}
const pick = this._pickFromMouse();
if (!pick) {
this._ghostMesh.setEnabled(false);
return;
}
const target = this._computePlacementCell(pick);
if (!target) {
this._ghostMesh.setEnabled(false);
return;
}
// Не показываем ghost если в этой клетке уже блок (только для tool=block)
if (this._activeTool === 'block' &&
this.blockManager?.hasBlock(target.x, target.y, target.z)) {
this._ghostMesh.setEnabled(false);
return;
}
this._ghostMesh.position = new Vector3(target.x, target.y + 0.5, target.z);
// Для модели — отображаем угол поворота через rotation (визуальная подсказка)
if (this._activeTool === 'model') {
this._ghostMesh.rotation.y = this._ghostRotationY;
} else {
this._ghostMesh.rotation.y = 0;
}
this._ghostMesh.setEnabled(true);
}
/**
* Высчитать целочисленную клетку (gridX, gridY, gridZ) куда ставить блок.
* Координаты — это нижний-передний-левый угол клетки (блок занимает
* (gridX..gridX+1, gridY..gridY+1, gridZ..gridZ+1)).
*
* Попали в блок: новая клетка = соседняя по нормали грани.
* Попали в пол: новая клетка = (round(p.x - 0.5), 0, round(p.z - 0.5)).
*
* Почему -0.5: точка p.x на полу — это координата в мире (0..40). Сетка
* целочисленная: блок «(0,0,0)» занимает (-0.5..0.5, 0..1, -0.5..0.5).
* Чтобы клик точно в центр клетки попал в (0,0,0), нужно округление
* без сдвига. Math.round(0.4) = 0, Math.round(0.6) = 1 — правильно.
*/
_computePlacementCell(pick) {
const p = pick.point;
const n = pick.normal || new Vector3(0, 1, 0);
const mesh = pick.mesh;
if (mesh?.metadata?.isBlock) {
// Соседняя клетка по нормали грани, в которую попали
const m = mesh.metadata;
const nx = Math.round(n.x);
const ny = Math.round(n.y);
const nz = Math.round(n.z);
const cell = {
x: m.gridX + nx,
y: m.gridY + ny,
z: m.gridZ + nz,
};
if (cell.y < 0) return null;
return cell;
}
// Попали в ТЕРРЕЙН (воксельный region-mesh или гладкий roblox-terrain).
// У этих мешей нет metadata.isBlock, но есть свои метки. Берём
// РЕАЛЬНУЮ точку пересечения луча (p.y) — это высота поверхности
// там, куда кликнули. Без этого модель вставала на y=0 (baseplate).
const md = mesh?.metadata;
const isTerrain = md && (md._isTerrainProto || md._isRegionMesh || md._isRobloxTerrain);
if (isTerrain) {
return {
x: Math.round(p.x),
y: p.y, // реальная высота поверхности под курсором
z: Math.round(p.z),
};
}
// Попали в пол / прочее. Точка p — мировая. Блок «(ix,iy,iz)» имеет
// центр на (ix, iy+0.5, iz), его горизонтальные грани (x,z) от (ix-0.5)
// до (ix+0.5). Поэтому простое Math.round(p.x) даёт верный gridX.
const x = Math.round(p.x);
const z = Math.round(p.z);
// Если попали в верхнюю грань пола → ставим на y=0.
// Если попали под низ пола (камера ниже сцены) → не ставим.
if (n.y < 0.5) return null;
return { x, y: 0, z };
}
/**
* Обработать клик мыши (вызывается из mouseup если это был клик, не drag).
* tool: 'block' / 'model' / 'erase' / 'select'.
*/
_handleEditorClick(shiftKey, ctrlKey = false) {
if (this._isPlaying) return;
if (!this.blockManager) return;
const pick = this._pickFromMouse();
if (!pick) {
if (this._activeTool === 'select' && !ctrlKey) {
this.selection?.clear();
}
return;
}
const tool = shiftKey ? 'erase' : this._activeTool;
if (tool === 'select') {
if (this.selection) {
// Для надёжности: если pick.mesh почему-то остался прото-мешем
// (без metadata.isBlock), пробуем разрезолвить через
// findProxyByPickInfo ещё раз.
let selectMesh = pick.mesh;
if (selectMesh?.metadata?._isBlockProto && this.blockManager) {
const proxy = this.blockManager.findProxyByPickInfo(pick.pickInfo);
if (proxy) selectMesh = proxy;
}
if (ctrlKey) {
this.selection.toggleMeshSelection(selectMesh);
} else {
this.selection.selectByMesh(selectMesh);
}
}
} else if (tool === 'block') {
const target = this._computePlacementCell(pick);
if (!target) return;
// Блоки живут в целочисленной сетке. Если кликнули по террейну,
// _computePlacementCell вернёт нецелый y (реальная высота
// поверхности) — округляем, чтобы блок встал ровно в клетку.
const by = Math.round(target.y);
const mesh = this.blockManager.addBlock(target.x, by, target.z, this._activeBlockType);
this._lastPlacedKey = `${target.x},${by},${target.z}`;
// Авто-выделение поставленного блока. Тени уже работают через proto-меш
// (зарегистрирован в refreshAllShadows и обновляется автоматически).
if (mesh) {
this.selection?.selectBlockAt(target.x, by, target.z);
if (this._onPostPlace) this._onPostPlace();
}
} else if (tool === 'model') {
if (!this._activeModelType) return;
const cell = this._computePlacementCell(pick);
if (!cell) return;
// Пользовательская voxel-модель (id 'user:<numericId>') — отдельный путь.
if (typeof this._activeModelType === 'string'
&& this._activeModelType.startsWith(USER_MODEL_PREFIX)) {
this.userModelManager.addInstance(
this._activeModelType, cell.x, cell.y, cell.z, this._ghostRotationY,
{ currentUserId: this._currentUserId || null },
).then(instId => {
if (instId != null) {
const data = this.userModelManager.instances.get(instId);
if (data?.meshes) {
for (const m of data.meshes) this.addShadowCaster(m);
}
// Регистрируем коллайдер
this._syncUserModelColliders();
if (this._onPostPlace) this._onPostPlace();
try { this._onSceneChange?.(); } catch (e) {}
// Инкремент uses_count — fire-and-forget
const numericId = parseUserModelId(this._activeModelType);
if (numericId != null && this._userModelsApi?.incrementModelUses) {
this._userModelsApi.incrementModelUses(numericId)
.catch(() => {});
}
}
});
return;
}
// addInstance модели — async, ждём id и выделяем
Promise.resolve(this.modelManager.addInstance(
this._activeModelType, cell.x, cell.y, cell.z, this._ghostRotationY
)).then(instId => {
if (instId != null) {
const data = this.modelManager.instances.get(instId);
if (data?.rootMesh && typeof data.rootMesh.getChildMeshes === 'function') {
// rootMesh — TransformNode, пропускаем его и берём только меши
for (const cm of data.rootMesh.getChildMeshes()) this.addShadowCaster(cm);
}
this.selection?.selectModelByInstanceId(instId);
if (this._onPostPlace) this._onPostPlace();
}
});
} else if (tool === 'primitive') {
if (!this._activePrimitiveType) return;
const cell = this._computePlacementCell(pick);
if (!cell) return;
// Примитив ставим так чтобы его НИЖНЯЯ грань была на клетке.
// Для куба/цилиндра/конуса pivot — центр, поэтому добавляем halfHeight.
const def = getPrimitiveType(this._activePrimitiveType);
const halfH = (def.defaultScale.y) / 2;
const newId = this.primitiveManager.addInstance(this._activePrimitiveType, {
x: cell.x, y: cell.y + halfH, z: cell.z,
});
// Авто-выделение поставленного примитива
if (newId != null) {
const data = this.primitiveManager.instances.get(newId);
if (data?.mesh) this.addShadowCaster(data.mesh);
this.selection?.selectPrimitiveById(newId);
if (this._onPostPlace) this._onPostPlace();
}
} else if (tool === 'erase') {
if (pick.mesh?.metadata?.isBlock) {
this.blockManager.removeBlockByMesh(pick.mesh);
} else if (pick.mesh?.metadata?.isModel) {
this.modelManager.removeInstanceByMesh(pick.mesh);
} else if (pick.mesh?.metadata?.isPrimitive) {
this.primitiveManager.removeInstanceByMesh(pick.mesh);
}
}
}
/**
* Drag-постановка/удаление блоков. Вызывается на mousemove когда ЛКМ
* удерживается и активен tool=block/erase.
*
* Чтобы блоки не «лезли на игрока» при ведении мышью по сцене, фиксируем
* плоскость первого блока (X/Y/Z в зависимости от грани попадания):
* - Кликнул на пол / верх блока → drag по горизонтали (фиксируем Y)
* - Кликнул на боковую грань (по X) → drag по вертикальной плоскости (фиксируем X)
* - и т.д.
*
* isFirst=true — это первый клик drag'а, запоминаем ось фиксации.
*/
_dragPlaceTick(shiftKey, isFirst = false) {
if (this._isPlaying || !this.blockManager) return;
const tool = shiftKey ? 'erase' : this._activeTool;
if (tool !== 'block' && tool !== 'erase') return;
const pick = this._pickFromMouse();
if (!pick) return;
if (tool === 'block') {
const target = this._computePlacementCell(pick);
if (!target) return;
// Первый клик — запоминаем ось фиксации по нормали попадания
if (isFirst) {
const n = pick.normal;
if (Math.abs(n.y) > 0.5) {
this._dragLockAxis = 'y';
this._dragLockValue = target.y;
} else if (Math.abs(n.x) > 0.5) {
this._dragLockAxis = 'x';
this._dragLockValue = target.x;
} else if (Math.abs(n.z) > 0.5) {
this._dragLockAxis = 'z';
this._dragLockValue = target.z;
} else {
this._dragLockAxis = null;
}
} else if (this._dragLockAxis) {
// На последующих движениях — переопределяем target в плоскости
if (target[this._dragLockAxis] !== this._dragLockValue) {
// Курсор ушёл с зафиксированной плоскости. Пересчитываем
// через raycast на полу/блоке, но с принудительной координатой.
target[this._dragLockAxis] = this._dragLockValue;
}
}
const key = `${target.x},${target.y},${target.z}`;
if (key === this._lastPlacedKey) return;
if (this.blockManager.hasBlock(target.x, target.y, target.z)) return;
this.blockManager.addBlock(target.x, target.y, target.z, this._activeBlockType);
this._lastPlacedKey = key;
} else if (tool === 'erase') {
if (pick.mesh?.metadata?.isBlock) {
const m = pick.mesh.metadata;
const key = `${m.gridX},${m.gridY},${m.gridZ}`;
if (key === this._lastPlacedKey) return;
this.blockManager.removeBlockByMesh(pick.mesh);
this._lastPlacedKey = key;
}
}
}
/**
* Обновить гизмо под текущее выделение.
*/
_updateGizmoForSelection(sel) {
if (!this._gizmo) return;
if (!sel) {
this._gizmo.attachTo(null);
return;
}
if (sel.type === 'block') {
this._gizmo.attachTo(sel.mesh);
} else if (sel.type === 'model' || sel.type === 'spawn'
|| sel.type === 'userModel') {
this._gizmo.attachTo(sel.rootMesh);
} else if (sel.type === 'primitive') {
this._gizmo.attachTo(sel.mesh);
}
// Переустанавливаем режим чтобы sub-gizmo (move/rotate/scale)
// гарантированно пересоздалась поверх нового attached-mesh.
// Без этого гизмо иногда оказывается привязанным к старому или null
// объекту и стрелки становятся «неактивными».
this._gizmo.refreshMode();
}
/**
* Гизмо манипулировал объектом — синхронизируем через SelectionManager.
* Тип операции (move/rotate/scale) определяется по режиму гизмо.
*/
_onGizmoDragEnd() {
if (!this.selection || !this._gizmo) return;
const sel = this.selection.getSelection();
if (!sel) return;
const mode = this._gizmo.getMode();
if (sel.type === 'block') {
if (mode === 'move') {
// Babylon-mesh.position — центр блока (gridX, gridY+0.5, gridZ)
const newX = Math.round(sel.mesh.position.x);
const newY = Math.round(sel.mesh.position.y - 0.5);
const newZ = Math.round(sel.mesh.position.z);
if (newX === sel.gridX && newY === sel.gridY && newZ === sel.gridZ) {
sel.mesh.position.set(sel.gridX, sel.gridY + 0.5, sel.gridZ);
return;
}
if (this.blockManager.hasBlock(newX, newY, newZ)) {
sel.mesh.position.set(sel.gridX, sel.gridY + 0.5, sel.gridZ);
return;
}
this.selection.moveSelectedBlock(newX, newY, newZ);
}
// Блоки не поворачиваем и не масштабируем (по дизайну voxel-сцены).
// Если пользователь дёрнул rotate/scale — игнорируем.
} else if (sel.type === 'model') {
const root = sel.rootMesh;
if (mode === 'move') {
this.selection.moveSelectedModel(root.position.x, root.position.y, root.position.z);
} else if (mode === 'rotate') {
this.selection.rotateSelectedModel(root.rotation.y);
} else if (mode === 'scale') {
// Берём средний масштаб (для равномерного скейла)
const avg = (root.scaling.x + root.scaling.y + root.scaling.z) / 3;
this.selection.scaleSelectedModel(avg);
}
} else if (sel.type === 'userModel') {
const root = sel.rootMesh;
if (mode === 'move') {
this.selection.moveSelectedUserModel(root.position.x, root.position.y, root.position.z);
} else if (mode === 'rotate') {
this.selection.rotateSelectedUserModel(root.rotation.y);
} else if (mode === 'scale') {
const avg = (root.scaling.x + root.scaling.y + root.scaling.z) / 3;
this.selection.scaleSelectedUserModel(avg);
}
} else if (sel.type === 'spawn') {
const root = sel.rootMesh;
if (mode === 'move') {
this.selection.moveSelectedSpawn(root.position.x, root.position.y, root.position.z);
}
} else if (sel.type === 'primitive') {
const root = sel.mesh;
if (mode === 'move') {
this.selection.moveSelectedPrimitive(root.position.x, root.position.y, root.position.z);
} else if (mode === 'rotate') {
// Сохраняем поворот в data → попадёт в serialize при save.
this.primitiveManager?.updateInstance(sel.id, {
rotationX: root.rotation.x,
rotationY: root.rotation.y,
rotationZ: root.rotation.z,
});
} else if (mode === 'scale') {
// Снимаем scaling до пересоздания (после _recreateMesh старый mesh dispose'ится).
const newSx = sel.sx * root.scaling.x;
const newSy = sel.sy * root.scaling.y;
const newSz = sel.sz * root.scaling.z;
root.scaling.set(1, 1, 1);
this.selection.resizeSelectedPrimitive(newSx, newSy, newSz);
// resizeSelectedPrimitive уже обновил sel.mesh на новый и
// вызвал _highlightMesh. Перепривязываем гизмо к новому mesh.
const updatedSel = this.selection.getSelection();
if (updatedSel?.mesh) {
this._gizmo.attachTo(updatedSel.mesh);
}
}
}
}
/** Публичный сеттер: переключить инструмент извне (из React-компонента). */
setActiveTool(toolName) {
this._activeTool = toolName;
if (this._ghostMesh) {
this._ghostMesh.setEnabled(toolName === 'block');
}
// Preview-кисть террейна. Если меш ещё не создан — создадим лениво
// при первом setTerrainBrush (это произойдёт в TerrainPanel useEffect).
if (toolName === 'terrain') {
if (!this._terrainBrushPreview && this._terrainBrush) {
this._updateTerrainBrushPreview();
}
if (this._terrainBrushPreview) {
this._terrainBrushPreview.setEnabled(true);
}
} else if (this._terrainBrushPreview) {
this._terrainBrushPreview.setEnabled(false);
}
}
/**
* Обновить состояние кисти ландшафта из TerrainPanel.
* Принимает частичный объект — то что не задано, не меняется.
*/
setTerrainBrush(patch) {
if (!this._terrainBrush) return;
const prevTool = this._terrainBrush.tool;
Object.assign(this._terrainBrush, patch || {});
this._updateTerrainBrushPreview();
// Инструмент «Выбрать деко»: включаем пикинг thin-instance декораций,
// при уходе с инструмента — выключаем и снимаем подсветку.
const nowPick = this._terrainBrush.tool === 'pickDeco';
const wasPick = prevTool === 'pickDeco';
if (nowPick !== wasPick) {
if (this._smoothDecoManager?.setPickingEnabled) {
this._smoothDecoManager.setPickingEnabled(nowPick);
}
if (!nowPick) this._clearDecoSelection();
}
}
/** Снять подсветку выбранной декорации (маркер-сфера). */
_clearDecoSelection() {
if (this._decoSelMarker) {
try { this._decoSelMarker.dispose(); } catch (e) {}
this._decoSelMarker = null;
}
this._decoSelection = null;
}
/**
* Клик инструментом «Выбрать деко»: raycast по thin-instance декорациям,
* подсветка выбранного дерева/куста маркером. Удаление — по Del
* (обрабатывается в _deleteSelectedDeco).
*/
_pickDecoTick() {
const dm = this._smoothDecoManager;
if (!dm) return;
const pick = this.scene.pick(this.scene.pointerX, this.scene.pointerY,
(m) => m.isPickable && m.name && m.name.startsWith('__smoothDeco_'));
if (!pick || !pick.hit || !pick.pickedMesh) {
this._clearDecoSelection();
return;
}
const thinIdx = pick.thinInstanceIndex;
const found = dm.findInstanceByPick(pick.pickedMesh, thinIdx);
if (!found) {
this._clearDecoSelection();
return;
}
this._decoSelection = found;
// Маркер-подсветка: жёлтая полупрозрачная сфера над выбранным деко.
if (this._decoSelMarker) {
try { this._decoSelMarker.dispose(); } catch (e) {}
}
const marker = MeshBuilder.CreateSphere('__decoSelMarker', { diameter: 3, segments: 10 }, this.scene);
const mat = new StandardMaterial('__decoSelMarkerMat', this.scene);
mat.emissiveColor = new Color3(1, 0.85, 0.1);
mat.alpha = 0.35;
mat.disableLighting = true;
marker.material = mat;
marker.isPickable = false;
marker.position.set(found.x, found.y + 2, found.z);
this._decoSelMarker = marker;
console.log(`[pickDeco] выбрана ${found.decoKey} @ (${found.x.toFixed(1)},${found.z.toFixed(1)})`);
}
/** Удалить выбранную инструментом «Выбрать деко» декорацию (вызов по Del). */
_deleteSelectedDeco() {
if (!this._decoSelection || !this._smoothDecoManager) return false;
const { decoKey, fullIndex } = this._decoSelection;
const ok = this._smoothDecoManager.removeInstanceAt(decoKey, fullIndex);
if (ok) {
// Пересинхронизировать tree-collider'ы (вдруг удалили дерево)
if (this.physics?.setSmoothDecoTrees) {
this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders());
}
this._clearDecoSelection();
try { this._onSceneChange?.(); } catch (e) {}
}
return ok;
}
/**
* Обновить geometry preview-меша кисти (полупрозрачная сфера/куб
* по форме кисти, размер 2*radius). Цвет берётся из текущего материала.
*/
_updateTerrainBrushPreview() {
if (!this._terrainBrush) return;
const { brushSize, shape, material } = this._terrainBrush;
const r = Math.max(1, brushSize);
// Берём цвет напрямую из палитры — стабильный hex, не зависит от того
// загружена ли уже текстура. Превью кисти просто тонируется в основной
// цвет выбранного материала.
let matCol = null;
try {
const def = TERRAIN_MATERIAL_DEFS?.[material];
if (def?.color) matCol = Color3.FromHexString(def.color);
} catch (e) {}
// Удаляем старый preview если форма/размер/режим изменились
if (this._terrainBrushPreview) {
const md = this._terrainBrushPreview.metadata || {};
const curMode = this._terrainBrush?.terrainMode || 'voxel';
if (md.shape !== shape || md.radius !== r || md.terrainMode !== curMode) {
try { this._terrainBrushPreview.dispose(); } catch (e) {}
this._terrainBrushPreview = null;
}
}
if (!this._terrainBrushPreview) {
// Размер кисти в МИРОВЫХ единицах:
// voxel-режим: r (voxel) × VOXEL_SIZE × 2 + 1 voxel (центр)
// smooth-режим: r — это РАДИУС В МЕТРАХ, диаметр = r*2
const isSmooth = this._terrainBrush?.terrainMode === 'smooth';
const worldDiameter = isSmooth
? r * 2
: (r * 2 + 1) * TERRAIN_VOXEL_SIZE;
let mesh;
if (shape === 'cube') {
mesh = MeshBuilder.CreateBox('__terrainBrushPreview', { size: worldDiameter }, this.scene);
} else if (shape === 'cylinder') {
mesh = MeshBuilder.CreateCylinder('__terrainBrushPreview', { height: worldDiameter, diameter: worldDiameter }, this.scene);
} else {
mesh = MeshBuilder.CreateSphere('__terrainBrushPreview', { diameter: worldDiameter, segments: 16 }, this.scene);
}
const mat = new StandardMaterial('__terrainBrushPreviewMat', this.scene);
mat.emissiveColor = matCol || new Color3(0.2, 0.7, 0.3);
mat.diffuseColor = new Color3(0, 0, 0);
mat.alpha = 0.22;
mat.disableLighting = false;
mat.backFaceCulling = false;
mesh.material = mat;
mesh.isPickable = false;
mesh.metadata = {
_isTerrainBrushPreview: true,
shape, radius: r,
terrainMode: this._terrainBrush?.terrainMode || 'voxel',
_baseAlpha: 0.22,
_activeAlpha: 0.45,
};
mesh.setEnabled(this._activeTool === 'terrain');
this._terrainBrushPreview = mesh;
} else {
// Обновляем цвет
if (matCol) {
try { this._terrainBrushPreview.material.emissiveColor = matCol; } catch (e) {}
}
}
}
/**
* Вычислить точку клика для кисти террейна. Возвращает {x, y, z} в
* voxel-координатах (целые) или null.
*
* Логика:
* 1. Если попали по существующему voxel'у — берём клетку или соседнюю
* по нормали (в зависимости от инструмента).
* 2. Иначе делаем raycast на плоскость y=0 (пол сцены) — это даёт
* площадку «где начнётся ландшафт».
*/
_pickTerrainCell(forNewVoxel) {
const pi = this.scene.pick(
this.scene.pointerX,
this.scene.pointerY,
(mesh) => {
if (!mesh.isPickable) return false;
if (mesh === this._ghostMesh) return false;
if (mesh === this._terrainBrushPreview) return false;
if (mesh.metadata?._isBlockProto) return false;
if (mesh.metadata?._isTerrainProto) return false;
if (mesh.name && mesh.name.startsWith('gridLine')) return false;
return true;
}
);
// Сначала — попытка raycast по существующему террейну (DDA)
const camera = this.scene.activeCamera;
const ray = this.scene.createPickingRay(
this.scene.pointerX,
this.scene.pointerY,
null,
camera,
false,
);
const tHit = this.terrainManager?.pickVoxelByRay(ray.origin, ray.direction, 200);
if (tHit) {
if (forNewVoxel) {
return {
x: tHit.cell.x + tHit.normal.x,
y: tHit.cell.y + tHit.normal.y,
z: tHit.cell.z + tHit.normal.z,
};
}
return tHit.cell;
}
// Иначе — попадание по обычным мешам (пол/блок). Делим мировые
// координаты на TERRAIN_VOXEL_SIZE чтобы получить voxel-индекс.
if (pi?.hit && pi.pickedPoint) {
const p = pi.pickedPoint;
const n = pi.getNormal?.(true) || { x: 0, y: 1, z: 0 };
// Идём на 0.001 внутрь по противоположной нормали — чтобы координата
// упала в правильный voxel.
const inside = {
x: p.x - n.x * 0.001,
y: p.y - n.y * 0.001,
z: p.z - n.z * 0.001,
};
const S = TERRAIN_VOXEL_SIZE;
const baseX = Math.floor(inside.x / S);
const baseY = Math.floor(inside.y / S);
const baseZ = Math.floor(inside.z / S);
if (forNewVoxel) {
return {
x: baseX + Math.round(n.x),
y: baseY + Math.round(n.y),
z: baseZ + Math.round(n.z),
};
}
return { x: baseX, y: baseY, z: baseZ };
}
// Fallback — raycast на плоскость y=0. Преобразуем мировые
// координаты в voxel-индексы делением на TERRAIN_VOXEL_SIZE.
if (Math.abs(ray.direction.y) > 1e-4) {
const t = -ray.origin.y / ray.direction.y;
if (t > 0 && t < 200) {
const hx = ray.origin.x + ray.direction.x * t;
const hz = ray.origin.z + ray.direction.z * t;
const S = TERRAIN_VOXEL_SIZE;
const cx = Math.floor(hx / S);
const cz = Math.floor(hz / S);
// Прилипание к верху столбца: если в этом столбце уже
// есть voxel'ы террейна, используем Y верхнего + 1.
// Это убирает «прыжок» кисти когда юзер начинает рисовать
// на пол рядом с уже существующим холмом — она остаётся
// на уровне его поверхности.
let topY = -1;
if (this.terrainManager) {
const found = this.terrainManager._findTopY?.(cx, cz, 200, -50);
if (found !== null && found !== undefined) topY = found;
}
return {
x: cx,
y: forNewVoxel && topY >= 0 ? topY + 1 : (topY >= 0 ? topY : 0),
z: cz,
};
}
}
return null;
}
/**
* Один «тик» кисти террейна. Вызывается при mousedown и mousemove с зажатой
* ЛКМ когда activeTool === 'terrain'.
*
* shiftKey — модификатор «обратное действие» (стереть/опустить).
*/
_terrainBrushTick(shiftKey, isFirst) {
if (this._isPlaying) return;
if (this._activeTool !== 'terrain') return;
// === Smooth-режим: редактируем DensityGrid через SmoothBrushes ===
if (this._terrainBrush?.terrainMode === 'smooth') {
this._smoothBrushTick(shiftKey, isFirst);
return;
}
if (!this.terrainManager) return;
const tool = this._terrainBrush?.tool || 'draw';
// === Rate-limit voxel-кисти ===
// mousemove приходит ~100Hz, каждый тик brushDraw/sculpt с radius=16
// = ~17000 thinInstanceAdd. Даже с GPU-batch это съедает кадр.
// Ограничиваем тики до ~25 Hz (40ms) — кисть всё равно плавно
// покрывает поверхность за счёт drag по экрану.
if (!isFirst) {
const now = performance.now();
const last = this._voxelBrushLastTick || 0;
const radius = this._terrainBrush?.brushSize || 4;
// Чем больше кисть, тем реже тики (защита от лагов).
const minInterval = radius <= 4 ? 30 : radius <= 8 ? 50 : radius <= 16 ? 80 : 120;
if (now - last < minInterval) return;
this._voxelBrushLastTick = now;
} else {
this._voxelBrushLastTick = performance.now();
}
// === Plant-кисти voxel-режима: размещение мини-воксельных моделей ===
// plantGrass / plantFlower / plantMushroom / plantTree.
// Shift = стереть декорации в зоне.
if (tool === 'plantGrass' || tool === 'plantFlower'
|| tool === 'plantMushroom' || tool === 'plantTree') {
// При новом клике сбрасываем rate-limit pos, чтобы первый клик
// в той же точке всегда срабатывал.
if (isFirst) {
this._voxelTreeLastPos = null;
this._voxelPlantLastPos = null;
}
const cell = this._pickTerrainCell(true);
if (!cell) return;
const brush = {
x: cell.x, y: cell.y, z: cell.z,
radius: this._terrainBrush.brushSize || 4,
shape: this._terrainBrush.shape || 'sphere',
strength: this._terrainBrush.strength ?? 50,
};
if (shiftKey) {
this._eraseDecorationsInBrush(brush);
} else {
this._placeVoxelPlantsAtBrush(brush, tool);
}
return;
}
// Для перекраски/выровнять — берём клетку с поверхности (не «над»)
// Для рисования — клетку «над» (по нормали)
const wantsAdjacent = (tool === 'draw' || tool === 'sculpt');
const cell = this._pickTerrainCell(wantsAdjacent);
if (!cell) return;
// На скульпте и выровнять — фиксируем Y первой точки, чтобы при drag
// не перепрыгивать на разные слои каждым движением мыши.
if (isFirst) {
this._terrainDragLockY = cell.y;
}
const brush = {
x: cell.x,
y: (tool === 'flatten' || tool === 'sculpt' || tool === 'smooth')
? this._terrainDragLockY ?? cell.y
: cell.y,
z: cell.z,
radius: this._terrainBrush.brushSize || 4,
shape: this._terrainBrush.shape || 'sphere',
};
const matId = this._terrainBrush.material || 'grass';
const strength = this._terrainBrush.strength ?? 50;
if (shiftKey) {
// Shift = обратная кисть. Для sculpt — опускает (Sculpt Down).
// Для всех остальных — стирает (voxels + декорации в зоне).
if (tool === 'sculpt') {
this.terrainManager.brushSculpt(brush, -1, matId, strength);
} else {
this.terrainManager.brushErase(brush);
this._eraseDecorationsInBrush(brush);
}
return;
}
// === Деко-материалы → мини-воксельные модели ===
// Если выбран материал-декорация (трава/цветы/грибы/листья) — кисть
// ставит МОДЕЛЬ из DecoModels, а не плоский voxel. Это для tool=draw,
// sculpt (рисование) — где пользователь "красит" декорациями.
// smooth/paint/flatten — стандартный voxel-rendering.
if (this._isDecoMaterial(matId) && (tool === 'draw' || tool === 'sculpt')) {
this._placeDecoModelsAtBrush(brush, matId);
return;
}
// Если выбран деко-материал но инструмент НЕ ставит модели (smooth/paint/flatten),
// нельзя засыпать столбцы декорациями — fallback на 'grass'.
const safeMatId = this._isDecoMaterial(matId) ? 'grass' : matId;
switch (tool) {
case 'draw':
this.terrainManager.brushDraw(brush, matId);
break;
case 'sculpt':
this.terrainManager.brushSculpt(brush, +1, matId, strength);
break;
case 'smooth':
// Smooth работает БЕЗ выбранного материала: засыпка идёт
// тем материалом, что уже есть у соседних solid voxels.
this.terrainManager.brushSmooth(brush, null);
break;
case 'paint':
this.terrainManager.brushPaint(brush, safeMatId);
break;
case 'flatten':
this.terrainManager.brushFlatten(brush, safeMatId);
break;
case 'erase':
// Стираем И voxels И декорации в зоне кисти.
this.terrainManager.brushErase(brush);
this._eraseDecorationsInBrush(brush);
break;
default:
break;
}
}
/** Переключение «активного» состояния preview-меша кисти террейна.
* active=true делает кисть ярче (alpha 0.45 vs 0.22) — пока зажата ЛКМ. */
_setTerrainBrushPreviewActive(active) {
const m = this._terrainBrushPreview;
if (!m || !m.material) return;
try {
const meta = m.metadata || {};
m.material.alpha = active ? (meta._activeAlpha || 0.45) : (meta._baseAlpha || 0.22);
} catch (e) {}
}
// ========================================================================
// Undo / Redo для террейна
//
// Хранится стек снапшотов всего террейна (массив serialize'данных).
// Один drag-мазок кистью = один снапшот. История ограничена 30 шагами
// (чтобы не съесть RAM при больших террейнах).
//
// Использование:
// _terrainHistoryOpen() — перед началом мазка (mousedown по террейну)
// _terrainHistoryClose() — после конца мазка (mouseup): если что-то
// изменилось, фиксируем «открытый» снапшот
// undoTerrain() / redoTerrain() — горячие клавиши Ctrl+Z / Ctrl+Y
// ========================================================================
/**
* Маппинг "деко-материалов" (выбираемых в палитре voxel-режима) на
* `modelId` из DECO_MODELS. Если material есть в этой карте — кисть
* ставит МОДЕЛЬ через DecoManager, а не плоский voxel.
*/
_decoMaterialToModels(matId) {
switch (matId) {
case 'tall_grass':
// Случайная модель травы из pool — каждый клик ставит разные
return GRASS_MODELS_POOL;
case 'flower_red': return ['poppy'];
case 'flower_blue': return ['cornflower'];
case 'flower_yellow': return ['dandelion', 'daisy'];
case 'mushroom_red': return ['fly_mushroom'];
// Эти материалы можно тоже спрятать под деко если хочется,
// но пока оставляем как voxels (они нужны для деревьев и т.п.)
// case 'leaves': case 'leaves_orange': case 'rock_moss': case 'trunk':
default: return null;
}
}
/** True если material — декорация (ставится моделью). */
_isDecoMaterial(matId) {
return this._decoMaterialToModels(matId) !== null;
}
/**
* Поставить мини-воксельные модели в зоне кисти (sphere/cube).
* Плотность — 30% точек grid в радиусе → штук 5-15 на клик.
*/
_placeDecoModelsAtBrush(brush, matId) {
if (!this.decoManager) return;
const models = this._decoMaterialToModels(matId);
if (!models || models.length === 0) return;
const TERRAIN_VOXEL = 0.25;
const r = brush.radius;
// brush.x/y/z в voxel-индексах террейна (cells 0.25м).
// Мировые координаты центра brush в МЕТРАХ.
const cx = (brush.x + 0.5) * TERRAIN_VOXEL;
const cz = (brush.z + 0.5) * TERRAIN_VOXEL;
// Top-surface Y: ищем верх solid voxels под brush.x,brush.z.
// Если на этой колонне нет voxel — ставим прямо на baseplate y=0.
let topVoxY = brush.y;
// count = ~ 8 случайных позиций
const COUNT = 10;
const placedKeys = new Set();
for (let i = 0; i < COUNT; i++) {
// Случайная точка в круге radius (в voxel-units)
const angle = Math.random() * Math.PI * 2;
const rr = Math.sqrt(Math.random()) * r;
const vx = brush.x + Math.cos(angle) * rr;
const vz = brush.z + Math.sin(angle) * rr;
const worldX = (vx + 0.5) * TERRAIN_VOXEL;
const worldZ = (vz + 0.5) * TERRAIN_VOXEL;
// Y: top surface — используем brush.y + 1 voxel (над поверхностью)
const worldY = (topVoxY + 1) * TERRAIN_VOXEL;
// Защита от дублирования: не ставим 2 модели в одну сетку 0.5м
const key = `${Math.floor(worldX*2)},${Math.floor(worldZ*2)}`;
if (placedKeys.has(key)) continue;
placedKeys.add(key);
// Случайная модель из набора
const modelId = models[Math.floor(Math.random() * models.length)];
const rotation = Math.random() * Math.PI * 2;
const scale = 0.9 + Math.random() * 0.3;
this.decoManager.placeModel(worldX, worldY, worldZ, modelId, rotation, scale);
}
try { this._onSceneChange?.(); } catch (e) {}
}
/**
* Поставить декорации plant-кистью voxel-режима по типу инструмента.
* tool: plantGrass | plantFlower | plantMushroom | plantTree.
* Trees — из voxel-блоков (trunk + leaves), остальные — мини-модели.
*/
_placeVoxelPlantsAtBrush(brush, tool) {
if (tool === 'plantTree') {
this._placeVoxelTreesAtBrush(brush);
return;
}
// Подбираем пул моделей по типу инструмента
let models;
let countMul; // множитель плотности на тик (как в smooth: grass=густо, flower=средне)
switch (tool) {
case 'plantGrass':
models = GRASS_MODELS_POOL;
countMul = 1.5;
break;
case 'plantFlower':
models = ['daisy', 'cornflower', 'poppy', 'dandelion'];
countMul = 1.0;
break;
case 'plantMushroom':
models = ['fly_mushroom', 'brown_mushroom'];
countMul = 0.5;
break;
default:
return;
}
if (!this.decoManager || !models || models.length === 0) return;
// Rate-limit между тиками: не ставим если кисть не сдвинулась.
const r = brush.radius;
const minDist = Math.max(1, r * 0.3);
const minDist2 = minDist * minDist;
if (this._voxelPlantLastPos) {
const dx = brush.x - this._voxelPlantLastPos.x;
const dz = brush.z - this._voxelPlantLastPos.z;
if (dx * dx + dz * dz < minDist2) return;
}
this._voxelPlantLastPos = { x: brush.x, z: brush.z };
const TERRAIN_VOXEL = 0.25;
const topVoxY = brush.y;
// Кол-во точек пропорционально радиусу и типу декорации.
const COUNT = Math.max(2, Math.min(16, Math.round(r * countMul)));
const placedKeys = new Set();
for (let i = 0; i < COUNT; i++) {
const angle = Math.random() * Math.PI * 2;
const rr = Math.sqrt(Math.random()) * r;
const vx = brush.x + Math.cos(angle) * rr;
const vz = brush.z + Math.sin(angle) * rr;
const worldX = (vx + 0.5) * TERRAIN_VOXEL;
const worldZ = (vz + 0.5) * TERRAIN_VOXEL;
const worldY = (topVoxY + 1) * TERRAIN_VOXEL;
const key = `${Math.floor(worldX*2)},${Math.floor(worldZ*2)}`;
if (placedKeys.has(key)) continue;
placedKeys.add(key);
const modelId = models[Math.floor(Math.random() * models.length)];
const rotation = Math.random() * Math.PI * 2;
const scale = 0.9 + Math.random() * 0.3;
this.decoManager.placeModel(worldX, worldY, worldZ, modelId, rotation, scale);
}
try { this._onSceneChange?.(); } catch (e) {}
}
/**
* Поставить ОДНО красивое процедурное дерево из voxel-блоков под кистью.
*
* Логика как в smooth-режиме (`_smoothBrushTickPlant`):
* - 1 дерево за тик (а не пучок)
* - rate-limit: если кисть не сдвинулась далеко, пропускаем тик
* - случайный выбор типа: oak / birch / autumn
*
* Использует `placeVoxelTree` из VoxelTreeBuilder.js (тот же алгоритм,
* который генерирует деревья в процедурном мире — толстый ствол,
* корни, ветви-зигзаги, главная крона + кроны на ветвях).
*/
_placeVoxelTreesAtBrush(brush) {
if (!this.terrainManager) return;
const tm = this.terrainManager;
// === Rate-limit между тиками ===
// Один тик = одно дерево. Если кисть не сдвинулась более чем на
// 0.4×radius (в voxel-units), пропускаем. Это убирает спам деревьев
// друг на друге при удержании ЛКМ.
const r = brush.radius;
const minDist = Math.max(2, r * 0.4);
const minDist2 = minDist * minDist;
if (this._voxelTreeLastPos) {
const dx = brush.x - this._voxelTreeLastPos.x;
const dz = brush.z - this._voxelTreeLastPos.z;
if (dx * dx + dz * dz < minDist2) return;
}
this._voxelTreeLastPos = { x: brush.x, z: brush.z };
// === Случайная точка в круге кисти (jitter) ===
const angle = Math.random() * Math.PI * 2;
const rr = Math.sqrt(Math.random()) * r * 0.5; // не до края — деревья ближе к центру
const tx = Math.round(brush.x + Math.cos(angle) * rr);
const tz = Math.round(brush.z + Math.sin(angle) * rr);
// === Top-surface для этой XZ ===
const topY = tm._findTopY?.(tx, tz, brush.y + r * 4, brush.y - r * 4);
const baseY = (topY === null || topY === undefined) ? brush.y : topY;
// === Размер дерева от strength (1..100) ===
// strength=10 → саженец (sizeScale=0.5)
// strength=50 → стандарт (sizeScale=1.0)
// strength=100 → большое (sizeScale=2.0)
const strength = brush.strength ?? 50;
const sizeScale = 0.5 + (strength / 100) * 1.5;
// === Случайный тип дерева ===
const type = TREE_TYPES[Math.floor(Math.random() * TREE_TYPES.length)];
// === Уникальный seed на каждое дерево — даёт разную форму ===
const seed = (Math.random() * 0x7fffffff) | 0;
// === Ставим voxels через batched setVoxel-fn ===
// tm._addInstance не обновляет GPU buffer в batch-режиме, делаем
// один flushBatch в конце. Это превращает ~300 add'ов в один upload.
tm._beginBatch?.();
let placed = 0;
try {
const setVoxelFn = (x, y, z, matId) => {
const k = `${x},${y},${z}`;
if (tm.voxels.has(k)) return;
tm._addInstance?.(k, x, y, z, matId);
tm.voxels.set(k, matId);
placed++;
};
placeVoxelTree(setVoxelFn, tx, baseY, tz, type, sizeScale, seed);
} finally {
tm._flushBatch?.();
}
if (placed > 0) {
try { tm._emit?.(); } catch (e) {}
try { this._onSceneChange?.(); } catch (e) {}
}
}
/** Удалить все декорации в зоне кисти (по миру). */
_eraseDecorationsInBrush(brush) {
if (!this.decoManager || !this.decoManager.placements) return;
const TERRAIN_VOXEL = 0.25;
const cx = (brush.x + 0.5) * TERRAIN_VOXEL;
const cz = (brush.z + 0.5) * TERRAIN_VOXEL;
const r = brush.radius * TERRAIN_VOXEL * 1.2; // чуть больше для удобства
const r2 = r * r;
const keep = [];
let removed = 0;
for (const p of this.decoManager.placements) {
const dx = p.x - cx;
const dz = p.z - cz;
if (dx * dx + dz * dz <= r2) {
removed++;
} else {
keep.push(p);
}
}
if (removed > 0) {
// Перезагружаем decoManager с обновлённым списком
this.decoManager.clear();
this.decoManager.loadFromArray(keep);
try { this._onSceneChange?.(); } catch (e) {}
}
}
_terrainHistoryEnsure() {
if (!this._terrainHistory) {
this._terrainHistory = { stack: [], cursor: -1, pending: null };
}
return this._terrainHistory;
}
_terrainHistoryOpen() {
const tm = this.terrainManager;
if (!tm) return;
const h = this._terrainHistoryEnsure();
// Снапшот до изменения
h.pending = tm.serialize();
}
_terrainHistoryClose() {
const tm = this.terrainManager;
if (!tm) return;
const h = this._terrainHistoryEnsure();
if (!h.pending) return;
const after = tm.serialize();
// Сравниваем размером — если ничего не изменилось, не пушим
if (after.length === h.pending.length && this._terrainSerEqual(after, h.pending)) {
h.pending = null;
return;
}
// Обрубаем все «впереди-курсора» (redo-стек после нового действия)
if (h.cursor < h.stack.length - 1) {
h.stack.length = h.cursor + 1;
}
h.stack.push(h.pending);
h.cursor = h.stack.length - 1;
// Ограничение 30 шагов
const MAX = 30;
while (h.stack.length > MAX) {
h.stack.shift();
h.cursor--;
}
h.pending = null;
}
_terrainSerEqual(a, b) {
// Сравнение двух serialize-массивов. O(n) с использованием Set.
if (a.length !== b.length) return false;
const sa = new Set();
for (const v of a) sa.add(`${v.x},${v.y},${v.z},${v.m}`);
for (const v of b) if (!sa.has(`${v.x},${v.y},${v.z},${v.m}`)) return false;
return true;
}
undoTerrain() {
const tm = this.terrainManager;
if (!tm) return false;
const h = this._terrainHistoryEnsure();
if (h.cursor < 0) return false;
// Текущий стейт в редо-позицию (cursor+1)
const current = tm.serialize();
const target = h.stack[h.cursor];
// Если на cursor лежит снапшот «до», нам нужно вернуться к нему.
// Cursor указывает на последнее ВОЗВРАТНОЕ состояние.
tm.loadFromArray(target);
// Записываем текущий стейт в позицию cursor+1 для возможного redo
h.stack[h.cursor + 1] = current;
h.cursor--;
return true;
}
redoTerrain() {
const tm = this.terrainManager;
if (!tm) return false;
const h = this._terrainHistoryEnsure();
if (h.cursor + 1 >= h.stack.length - 0) return false;
const target = h.stack[h.cursor + 2];
if (!target) return false;
tm.loadFromArray(target);
h.cursor++;
return true;
}
// ========================================================================
// Регион террейна (инструменты «Выделить», «Заполнить», «Преобразовать»)
//
// Регион — это объёмная коробка, заданная двумя углами в voxel-индексах.
// Визуализируется wireframe-боксом. Создаётся drag-rectangle'ом на земле:
// первый mousedown в режиме «Выделить» — стартовый угол, drag — второй.
// Высота коробки фиксируется ±radius по Y вокруг плоскости клика.
//
// Регион используется:
// • «Заполнить» — залить регион выбранным материалом
// • «Преобразовать» — переместить все voxel'ы региона в новое место
// (плоский drag по XZ, без поворота на этапе 2).
// ========================================================================
/** Текущее выделение или null. Структура: {x0,y0,z0,x1,y1,z1} (включительно). */
getTerrainRegion() { return this._terrainRegion || null; }
/** Очистить выделение и убрать визуализацию. */
clearTerrainRegion() {
this._terrainRegion = null;
if (this._terrainRegionMesh) {
try { this._terrainRegionMesh.dispose(); } catch (e) {}
this._terrainRegionMesh = null;
}
}
/** Обновить wireframe-визуализацию региона по this._terrainRegion. */
_updateTerrainRegionVisual() {
const r = this._terrainRegion;
if (this._terrainRegionMesh) {
try { this._terrainRegionMesh.dispose(); } catch (e) {}
this._terrainRegionMesh = null;
}
if (!r) return;
const S = TERRAIN_VOXEL_SIZE;
const minX = Math.min(r.x0, r.x1);
const maxX = Math.max(r.x0, r.x1);
const minY = Math.min(r.y0, r.y1);
const maxY = Math.max(r.y0, r.y1);
const minZ = Math.min(r.z0, r.z1);
const maxZ = Math.max(r.z0, r.z1);
// Размер в мире — кол-во клеток × VOXEL_SIZE. +1 потому что включительно.
const sizeX = (maxX - minX + 1) * S;
const sizeY = (maxY - minY + 1) * S;
const sizeZ = (maxZ - minZ + 1) * S;
const cx = (minX + 0.5) * S + (sizeX - S) / 2;
const cy = (minY + 0.5) * S + (sizeY - S) / 2;
const cz = (minZ + 0.5) * S + (sizeZ - S) / 2;
const mesh = MeshBuilder.CreateBox('__terrainRegion', {
width: sizeX, height: sizeY, depth: sizeZ,
}, this.scene);
mesh.position.set(cx, cy, cz);
mesh.isPickable = false;
const mat = new StandardMaterial('__terrainRegionMat', this.scene);
mat.wireframe = true;
mat.emissiveColor = new Color3(0.20, 0.55, 1.00);
mat.diffuseColor = new Color3(0, 0, 0);
mat.alpha = 0.9;
mesh.material = mat;
mesh.metadata = { _isTerrainRegion: true };
this._terrainRegionMesh = mesh;
}
/** Запустить выделение региона: pickStart — voxel-клетка начала. */
_terrainBeginRegion(pickStart) {
const radius = this._terrainBrush?.brushSize || 4;
// Высота региона по умолчанию = ±radius от Y клика. При drag юзер
// может уточнить — но через первый MVP оставим фиксированной.
this._terrainRegion = {
x0: pickStart.x, y0: Math.max(0, pickStart.y - radius), z0: pickStart.z,
x1: pickStart.x, y1: pickStart.y + radius, z1: pickStart.z,
};
this._terrainRegionDragging = true;
this._updateTerrainRegionVisual();
}
/** Обновить второй угол региона по новой voxel-клетке. */
_terrainUpdateRegion(pickEnd) {
if (!this._terrainRegion) return;
this._terrainRegion.x1 = pickEnd.x;
this._terrainRegion.z1 = pickEnd.z;
// Y оставляем как поставили при начале — drag по плоскости XZ
this._updateTerrainRegionVisual();
}
/** Завершить drag выделения. */
_terrainEndRegion() {
this._terrainRegionDragging = false;
}
/**
* Инициализировать пустой smooth-terrain для скульптинга с нуля.
* Создаёт DensityGrid 100×24×100 cells (400×96×400м) с density=0 везде.
* Первый клик sculpt-кистью сразу породит холм в нужном месте.
*/
_initEmptySmoothTerrain() {
if (this._robloxTerrain) {
try { this._robloxTerrain.disposeAll(); } catch (e) {}
}
this._robloxTerrain = new RobloxTerrain(this.scene);
if (this.physics?.setRobloxTerrain) {
this.physics.setRobloxTerrain(this._robloxTerrain);
}
const sx = 100, sy = 24, sz = 100;
const grid = new RobloxDensityGrid({
origin: { x: -sx / 2 | 0, y: 0, z: -sz / 2 | 0 },
size: { x: sx, y: sy, z: sz },
});
// Регистрируем стандартные материалы в палитре (нужно для brushes).
// Index 0 = пусто, далее по порядку для совместимости с web/Android.
for (const matKey of ['grass', 'rock', 'sand', 'snow', 'dirt']) {
// Hack: set одной ячейки потом обнуляем, чтобы добавить в palette.
grid.set(0, 0, 0, 0, matKey);
}
// Сбрасываем (0,0,0) обратно в пусто — но matData[0] остался matId
// последнего set'а. Обнуляем явно.
grid.densityData[0] = 0;
grid.matData[0] = 0;
// skipEmpty: true — НЕ добавляем 98 пустых chunks в pending,
// mesher будет работать только после первой кисти.
this._robloxTerrain.loadFromGrid(grid, { skipEmpty: true });
// НЕ отключаем baseplate сразу — нужен чтобы raycast мог пикать
// плоскость y=0 при первых кликах. Отключим когда появится хоть один
// solid chunk (см. updateStreaming / applyBrushAndRebuild).
console.log('[BabylonScene] _initEmptySmoothTerrain: 100×24×100 grid created (skipEmpty=true)');
}
/**
* Тик smooth-кисти. Делает raycast по mesh-ам smooth-terrain,
* получает worldPosition и вызывает applyBrush в этой точке.
*
* Если smooth-terrain ещё не создан (свежий проект, нажимаем sculpt
* на пустой сцене) — создаём пустой DensityGrid 100×24×100 cells
* (400×96×400м) при первом клике sculpt/fill, как в Roblox Studio.
*/
_smoothBrushTick(shiftKey, isFirst) {
const tool = this._terrainBrush?.tool || 'sculpt';
// === Инструмент «Выбрать деко» — поштучный клик-выбор декораций ===
// Не модифицирует ландшафт, работает только на клик (не drag).
if (tool === 'pickDeco') {
if (isFirst) this._pickDecoTick();
return;
}
const terrainEmpty = !this._robloxTerrain || !this._robloxTerrain.grid;
// === Счётчики для диагностики ===
if (!this._smoothBrushDiag) {
this._smoothBrushDiag = {
tickCount: 0, hitTerrain: 0, hitGround: 0, hitNone: 0,
applyResult: { built: 0, dirty0: 0 },
};
}
const D = this._smoothBrushDiag;
D.tickCount++;
const tickN = D.tickCount;
// === Plane-lock + rate-limit (как в Roblox Studio) ===
// При isFirst — фиксируем плоскость и позицию первого клика.
// Дальше при drag:
// 1) center.y берётся не из raycast (он растёт вверх вслед за рельефом),
// а из зафиксированного _smoothBrushLockY → кисть работает в плоскости.
// 2) Между tick'ами требуется минимальное расстояние (0.6×radius) —
// иначе одна и та же точка → cells доходят до 255 → "какаха".
if (isFirst) {
this._smoothBrushLockY = null;
this._smoothBrushLastPos = null;
}
// === Raycast — выбираем точку под курсором ===
let hit = null;
let pickSource = '';
if (!terrainEmpty) {
const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain);
hit = this.scene.pickWithRay(
this.scene.createPickingRay(this.scene.pointerX, this.scene.pointerY, null, this.camera),
pickPred,
);
if (hit && hit.hit) { pickSource = 'terrain'; D.hitTerrain++; }
}
if (!hit || !hit.hit || !hit.pickedPoint) {
const groundPick = this.scene.pick(this.scene.pointerX, this.scene.pointerY,
(m) => m.name === 'editorGround');
if (groundPick && groundPick.hit && groundPick.pickedPoint) {
hit = groundPick;
pickSource = 'ground';
D.hitGround++;
} else {
D.hitNone++;
if (isFirst || tickN % 20 === 0) {
console.log(`[SmoothBrush] tick#${tickN} isFirst=${isFirst} → NO HIT (terrainEmpty=${terrainEmpty}, pointer=${this.scene.pointerX},${this.scene.pointerY})`);
}
return;
}
}
const worldPt = hit.pickedPoint;
// === Инициализация пустого grid при первом клике sculpt/fill ===
let initialized = false;
if (terrainEmpty) {
if (!isFirst) {
if (tickN <= 5 || tickN % 30 === 0) {
console.log(`[SmoothBrush] tick#${tickN} isFirst=false, terrainEmpty=true → skip (нужен первый клик)`);
}
return;
}
if (tool !== 'sculpt' && tool !== 'draw' && tool !== 'fill') {
console.log(`[SmoothBrush] tick#${tickN} terrainEmpty + tool='${tool}' → нельзя инициализировать (используйте sculpt/draw/fill)`);
return;
}
console.log(`[SmoothBrush] tick#${tickN} INIT empty grid (tool='${tool}', hit at ${worldPt.x.toFixed(1)},${worldPt.y.toFixed(1)},${worldPt.z.toFixed(1)})`);
this._initEmptySmoothTerrain();
initialized = true;
if (!this._robloxTerrain || !this._robloxTerrain.grid) {
console.warn(`[SmoothBrush] tick#${tickN} init FAILED — grid still null`);
return;
}
}
const material = this._terrainBrush?.material || 'grass';
const radius = Math.max(3, (this._terrainBrush?.brushSize || 4) * 2.0);
// strength: slider 0..100 → реальная strength 60..400 для sculpt.
// Минимум 60 чтобы edge influence (~0.05) давал delta=3 → cells
// на краю кисти достигали threshold 128 за 40 тиков а не 128.
// Максимум 400 = мгновенно ставит cells в 255 (полная заливка).
const strengthSlider = this._terrainBrush?.strength ?? 50;
const strength = 60 + (strengthSlider / 100) * 340;
let brushType;
if (shiftKey) {
brushType = 'sculptDown';
} else {
switch (tool) {
case 'draw': brushType = 'sculptUp'; break;
case 'sculpt': brushType = 'sculptUp'; break;
case 'smooth': brushType = 'smooth'; break;
case 'paint': brushType = 'paint'; break;
case 'flatten':
if (isFirst) this._smoothFlattenTargetY = worldPt.y;
brushType = 'flatten';
break;
case 'fill': brushType = 'fill'; break;
case 'erase': brushType = 'erase'; break;
// === Plant-кисти: добавление декораций ===
case 'plantGrass': brushType = 'plantGrass'; break;
case 'plantFlower': brushType = 'plantFlower'; break;
case 'plantMushroom': brushType = 'plantMushroom'; break;
case 'plantTree': brushType = 'plantTree'; break;
default: brushType = 'sculptUp';
}
}
// === Plant-кисти обрабатываются ОТДЕЛЬНО от sculpt-логики ===
// Они НЕ модифицируют DensityGrid — добавляют thin-instance модели.
if (brushType.startsWith('plant')) {
return this._smoothBrushTickPlant(brushType, worldPt, radius, shiftKey, isFirst);
}
// === Plane-lock: для sculpt-кистей фиксируем Y первого клика ===
// Без этого при drag в одной XZ-точке raycast возвращает всё более
// высокий Y (рельеф рос на прошлых тиках) → кисть смещается ВВЕРХ
// рельеф летит на камеру ("какаха").
// С lock'ом drag работает в горизонтальной плоскости фиксированной
// высоты первого клика, как в Roblox Studio.
const isSculptKind = brushType === 'sculptUp' || brushType === 'sculptDown';
let centerY = worldPt.y;
if (isSculptKind) {
if (this._smoothBrushLockY === null) {
this._smoothBrushLockY = worldPt.y;
}
centerY = this._smoothBrushLockY;
// Смещение от плоскости первого клика на radius×0.5
// (вверх для sculptUp, вниз для sculptDown).
if (brushType === 'sculptUp') centerY += radius * 0.5;
else centerY -= radius * 0.5;
}
// === Rate-limit: пропускаем tick если кисть не сдвинулась далеко ===
// Между тиками должно быть >= 0.6×radius по XZ. Это убивает feedback
// loop в одной точке.
if (isSculptKind && !isFirst && this._smoothBrushLastPos) {
const dx = worldPt.x - this._smoothBrushLastPos.x;
const dz = worldPt.z - this._smoothBrushLastPos.z;
const minDist = radius * 0.6;
if (dx * dx + dz * dz < minDist * minDist) {
// Кисть в той же точке — пропускаем (но НЕ для isFirst).
return;
}
}
if (isSculptKind) {
this._smoothBrushLastPos = { x: worldPt.x, z: worldPt.z };
}
const params = {
center: { x: worldPt.x, y: centerY, z: worldPt.z },
radius,
strength,
material,
targetY: this._smoothFlattenTargetY,
};
const tApply0 = performance.now();
const built = this._robloxTerrain.applyBrushAndRebuild(brushType, params);
const tApply = performance.now() - tApply0;
if (built > 0) D.applyResult.built += built;
else D.applyResult.dirty0++;
if (isFirst || tickN <= 5 || tickN % 20 === 0 || initialized || built === 0) {
const gridStats = this._robloxTerrain.grid
? `solid=${this._robloxTerrain.grid.countSolid?.() ?? '?'}`
: 'no-grid';
console.log(
`[SmoothBrush] tick#${tickN} ${brushType} slider=${strengthSlider} → strength=${strength.toFixed(0)} `
+ `pick='${pickSource}' @(${worldPt.x.toFixed(1)},${worldPt.y.toFixed(1)},${worldPt.z.toFixed(1)}) `
+ `r=${radius} mat=${material} ${gridStats} `
+ `→ built=${built} chunks in ${tApply.toFixed(0)}ms `
+ (initialized ? ' [INITIALIZED!]' : '')
+ (built === 0 ? ' [NO CHANGE]' : ''),
);
}
try { this._onSceneChange?.(); } catch (e) {}
}
/**
* Plant-кисть: расставляет/удаляет декорации (трава/цветы/грибы/деревья).
* Не трогает DensityGrid — работает только с SmoothDecoManager.
* Shift = ластик (удалить декорации в радиусе).
*/
_smoothBrushTickPlant(brushType, worldPt, radius, shiftKey, isFirst) {
// Нужен SmoothDecoManager (создаём lazy при первом plant-клике)
if (!this._smoothDecoManager) {
this._smoothDecoManager = new SmoothDecoManager(this.scene);
this._smoothDecoManager.loadAll();
}
// Rate-limit как в sculpt: пропускаем близкие тики
if (!isFirst && this._smoothBrushLastPos) {
const dx = worldPt.x - this._smoothBrushLastPos.x;
const dz = worldPt.z - this._smoothBrushLastPos.z;
const minDist = radius * 0.4; // плотнее чем sculpt (декорации мелкие)
if (dx * dx + dz * dz < minDist * minDist) return;
}
this._smoothBrushLastPos = { x: worldPt.x, z: worldPt.z };
// Shift — ластик
if (shiftKey) {
const removed = this._smoothDecoManager.removeBrushDecoInRadius(
{ x: worldPt.x, z: worldPt.z }, radius,
);
if (removed > 0) {
console.log(`[SmoothBrush] erased ${removed} decorations at (${worldPt.x.toFixed(1)},${worldPt.z.toFixed(1)})`);
// Пересинхронизировать tree-colliders в physics
if (this.physics?.setSmoothDecoTrees) {
this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders());
}
try { this._onSceneChange?.(); } catch (e) {}
}
return;
}
// Маппинг brushType → kind для SmoothDecoManager
const kindMap = {
plantGrass: 'grass',
plantFlower: 'flower',
plantMushroom: 'mushroom',
plantTree: 'tree',
};
const kind = kindMap[brushType];
if (!kind) return;
// Количество инстансов за один тик зависит от типа.
// Трава густая, деревья редко.
const countMap = { grass: 6, flower: 4, mushroom: 2, tree: 1 };
const count = countMap[kind] || 3;
// Surface-Y хелпер: raycast по smooth-terrain ИЛИ ground (y=0).
const sampleSurfaceY = (x, z) => {
if (this.physics?._sampleRobloxSurface) {
const y = this.physics._sampleRobloxSurface(x, z);
if (y !== null) return y;
}
// Fallback на ground y=0
return 0;
};
const result = this._smoothDecoManager.addBrushDeco({
kind,
center: { x: worldPt.x, y: worldPt.y, z: worldPt.z },
radius,
count,
sampleSurfaceY,
});
const added = result.added || 0;
if (added > 0) {
console.log(`[SmoothBrush] planted ${added} ${kind} at (${worldPt.x.toFixed(1)},${worldPt.z.toFixed(1)})`);
// Если посадили деревья — пересинхронизировать tree-colliders
// в physics (полная переустановка через getAllTreeColliders).
if (kind === 'tree' && this.physics?.setSmoothDecoTrees) {
this.physics.setSmoothDecoTrees(this._smoothDecoManager.getAllTreeColliders());
}
try { this._onSceneChange?.(); } catch (e) {}
}
}
/** Залить выделенный регион материалом. Используется инструментом
* «Заполнить» когда есть активный _terrainRegion. */
_terrainFillRegion(matId) {
const r = this._terrainRegion;
const tm = this.terrainManager;
if (!r || !tm) return 0;
const minX = Math.min(r.x0, r.x1);
const maxX = Math.max(r.x0, r.x1);
const minY = Math.min(r.y0, r.y1);
const maxY = Math.max(r.y0, r.y1);
const minZ = Math.min(r.z0, r.z1);
const maxZ = Math.max(r.z0, r.z1);
let n = 0;
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) {
for (let z = minZ; z <= maxZ; z++) {
const key = `${x},${y},${z}`;
if (tm.voxels.has(key)) continue;
tm.setVoxel(x, y, z, matId);
n++;
}
}
}
return n;
}
/** Переместить весь регион на dx/dy/dz в voxel-клетках. */
_terrainMoveRegion(dx, dy, dz) {
const r = this._terrainRegion;
const tm = this.terrainManager;
if (!r || !tm) return 0;
if (dx === 0 && dy === 0 && dz === 0) return 0;
const minX = Math.min(r.x0, r.x1);
const maxX = Math.max(r.x0, r.x1);
const minY = Math.min(r.y0, r.y1);
const maxY = Math.max(r.y0, r.y1);
const minZ = Math.min(r.z0, r.z1);
const maxZ = Math.max(r.z0, r.z1);
// Собираем содержимое региона
const collected = [];
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) {
for (let z = minZ; z <= maxZ; z++) {
const m = tm.getVoxel(x, y, z);
if (!m) continue;
collected.push({ x, y, z, m });
}
}
}
// Удаляем из старых позиций
for (const v of collected) tm.removeVoxel(v.x, v.y, v.z);
// Ставим в новые
let n = 0;
for (const v of collected) {
tm.setVoxel(v.x + dx, v.y + dy, v.z + dz, v.m);
n++;
}
// Сдвигаем сам регион
r.x0 += dx; r.x1 += dx;
r.y0 += dy; r.y1 += dy;
r.z0 += dz; r.z1 += dz;
this._updateTerrainRegionVisual();
return n;
}
/** Двигать preview-меш под курсор. Вызывается из mousemove. */
_updateTerrainBrushPosition() {
if (this._activeTool !== 'terrain') return;
if (!this._terrainBrushPreview) return;
// === Smooth-режим: raycast по smooth-mesh, preview на surface ===
if (this._terrainBrush?.terrainMode === 'smooth' && this._robloxTerrain?.grid) {
const ray = this.scene.createPickingRay(
this.scene.pointerX, this.scene.pointerY,
null, this.camera,
);
const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain);
const hit = this.scene.pickWithRay(ray, pickPred);
if (hit && hit.hit && hit.pickedPoint) {
this._terrainBrushPreview.position.copyFrom(hit.pickedPoint);
}
return;
}
// Voxel-режим (как было)
const cell = this._pickTerrainCell(false);
if (!cell) return;
const S = TERRAIN_VOXEL_SIZE;
this._terrainBrushPreview.position.set(
(cell.x + 0.5) * S,
(cell.y + 0.5) * S,
(cell.z + 0.5) * S,
);
}
/** Публичный сеттер: выбрать тип блока для постановки. */
setActiveBlockType(blockTypeId) {
this._activeBlockType = blockTypeId;
}
/** Публичный сеттер: выбрать тип модели для постановки. */
setActiveModelType(modelTypeId) {
this._activeModelType = modelTypeId;
}
/** Тип примитива для постановки (cube/sphere/...). */
setActivePrimitiveType(typeId) {
this._activePrimitiveType = typeId;
}
getPrimitiveCount() {
return this.primitiveManager ? this.primitiveManager.getInstanceCount() : 0;
}
/** Количество блоков (для status bar). */
getBlockCount() {
return this.blockManager ? this.blockManager.count() : 0;
}
/** Количество моделей-инстансов. */
getModelCount() {
return this.modelManager ? this.modelManager.getInstanceCount() : 0;
}
/** Подписаться на изменение выделения (UI / Inspector / Hierarchy). */
setOnSelectionChange(cb) {
if (this.selection) {
// Объединяем со внутренней подпиской на gizmo
this.selection.setOnSelectionChange((sel) => {
this._updateGizmoForSelection(sel);
if (cb) cb(sel);
});
}
}
/** Текущее выделение (или null). */
getSelection() {
return this.selection?.getSelection() || null;
}
/** Выделить блок программно (например по клику в Hierarchy). */
selectBlockAt(x, y, z) {
this.selection?.selectBlockAt(x, y, z);
}
/** Выделить модель программно. */
selectModelByInstanceId(id) {
this.selection?.selectModelByInstanceId(id);
}
/** Снять выделение. */
clearSelection() {
this.selection?.clear();
}
/** Удалить выделенный объект. */
deleteSelected() {
this.selection?.deleteSelected();
}
/**
* Дублировать выделенный объект (Ctrl+D).
* Блок: создаёт копию в соседней клетке (по +X, или -X если +X занят, или +Y).
* Модель: создаёт копию со смещением +1 по X.
*/
duplicateSelected() {
const sel = this.selection?.getSelection();
if (!sel) return;
if (sel.type === 'block') {
// Ищем свободную клетку рядом
const candidates = [
[1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1], [0, 1, 0],
];
for (const [dx, dy, dz] of candidates) {
const nx = sel.gridX + dx, ny = sel.gridY + dy, nz = sel.gridZ + dz;
if (ny < 0) continue;
if (!this.blockManager.hasBlock(nx, ny, nz)) {
this.blockManager.addBlock(nx, ny, nz, sel.blockTypeId);
this.selection.selectBlockAt(nx, ny, nz);
return;
}
}
} else if (sel.type === 'model') {
// Сохраняем все нужные поля выделения до того как промис завершится
// (selection может перезатереться к моменту resolve)
const typeId = sel.modelTypeId;
const sx = sel.x, sy = sel.y, sz = sel.z;
const rotY = sel.rotationY || 0;
this.modelManager.addInstance(typeId, sx + 1, sy, sz, rotY)
.then(newId => {
if (newId != null) this.selection?.selectModelByInstanceId(newId);
})
.catch(err => {
// eslint-disable-next-line no-console
console.error('[BabylonScene] duplicate model error:', err);
});
} else if (sel.type === 'userModel') {
const typeId = sel.userModelTypeId;
const sx = sel.x, sy = sel.y, sz = sel.z;
const rotY = sel.rotationY || 0;
this.userModelManager.addInstance(typeId, sx + 1, sy, sz, rotY, {
currentUserId: this._currentUserId || null,
}).then(newId => {
if (newId != null) this.selection?.selectUserModelByInstanceId(newId);
}).catch(err => {
console.error('[BabylonScene] duplicate user model error:', err);
});
} else if (sel.type === 'primitive') {
const newId = this.primitiveManager.addInstance(sel.primitiveType, {
x: sel.x + 1, y: sel.y, z: sel.z,
sx: sel.sx, sy: sel.sy, sz: sel.sz,
color: sel.color, material: sel.material,
canCollide: sel.canCollide, visible: sel.visible,
anchored: sel.anchored,
// Копируем и спец-свойства: текстуру, параметры лампы/эмиттера.
textureAsset: sel.textureAsset || null,
brightness: sel.brightness, range: sel.range, effect: sel.effect,
});
if (newId != null) this.selection.selectPrimitiveById(newId);
}
}
/**
* Скопировать выделенный объект в буфер обмена (Ctrl+C, Фаза 5.10).
* Буфер — localStorage, поэтому переживает перезагрузку страницы
* и смену проекта (Copy/Paste между проектами).
*/
copySelected() {
const sel = this.selection?.getSelection();
if (!sel) return;
let clip = null;
if (sel.type === 'block') {
clip = { kind: 'block', blockTypeId: sel.blockTypeId };
} else if (sel.type === 'model') {
clip = {
kind: 'model', modelTypeId: sel.modelTypeId,
rotationY: sel.rotationY || 0, scale: sel.scale || 1,
};
} else if (sel.type === 'userModel') {
clip = {
kind: 'userModel', userModelTypeId: sel.userModelTypeId,
rotationY: sel.rotationY || 0,
};
} else if (sel.type === 'primitive') {
clip = {
kind: 'primitive', primitiveType: sel.primitiveType,
sx: sel.sx, sy: sel.sy, sz: sel.sz,
color: sel.color, material: sel.material,
canCollide: sel.canCollide, visible: sel.visible,
anchored: sel.anchored,
textureAsset: sel.textureAsset || null,
brightness: sel.brightness, range: sel.range, effect: sel.effect,
};
}
if (clip) {
try { localStorage.setItem('kubikon_clipboard', JSON.stringify(clip)); }
catch (e) { /* ignore — приватный режим / переполнение */ }
}
}
/**
* Вставить объект из буфера обмена (Ctrl+V, Фаза 5.10).
* Объект появляется у точки, куда смотрит редактор-камера.
*/
pasteFromClipboard() {
let clip;
try {
const raw = localStorage.getItem('kubikon_clipboard');
if (!raw) return;
clip = JSON.parse(raw);
} catch (e) { return; }
if (!clip || !clip.kind) return;
// Точка вставки — перед редактор-камерой (~6м по направлению взгляда).
const cam = this.camera;
let px = 0, py = 1, pz = 0;
if (cam) {
const fwd = cam.getForwardRay ? cam.getForwardRay().direction : null;
if (fwd) {
px = cam.position.x + fwd.x * 6;
pz = cam.position.z + fwd.z * 6;
}
}
if (clip.kind === 'block') {
const gx = Math.round(px), gz = Math.round(pz);
let gy = 0;
while (gy < 64 && this.blockManager?.hasBlock(gx, gy, gz)) gy++;
this.blockManager?.addBlock(gx, gy, gz, clip.blockTypeId);
this.selection?.selectBlockAt(gx, gy, gz);
} else if (clip.kind === 'model') {
this.modelManager?.addInstance(clip.modelTypeId, px, py, pz, clip.rotationY || 0)
.then(id => { if (id != null) this.selection?.selectModelByInstanceId(id); })
.catch(() => {});
} else if (clip.kind === 'userModel') {
this.userModelManager?.addInstance(
clip.userModelTypeId, px, py, pz, clip.rotationY || 0,
{ currentUserId: this._currentUserId || null },
).then(id => { if (id != null) this.selection?.selectUserModelByInstanceId(id); })
.catch(() => {});
} else if (clip.kind === 'primitive') {
const id = this.primitiveManager?.addInstance(clip.primitiveType, {
x: px, y: Math.max(py, (clip.sy || 1) / 2), z: pz,
sx: clip.sx, sy: clip.sy, sz: clip.sz,
color: clip.color, material: clip.material,
canCollide: clip.canCollide, visible: clip.visible,
anchored: clip.anchored,
textureAsset: clip.textureAsset || null,
brightness: clip.brightness, range: clip.range, effect: clip.effect,
});
if (id != null) this.selection?.selectPrimitiveById(id);
}
if (this._onSceneChange) this._onSceneChange();
}
/**
* Поставить выделенный объект на пол (y = 0).
* Для блоков — gridY=0. Для моделей — нижняя граница на полу. Для примитивов — sy/2.
*/
alignSelectedToFloor() {
const sel = this.selection?.getSelection();
if (!sel) return;
if (sel.type === 'block') {
this.selection.moveSelectedBlock(sel.gridX, 0, sel.gridZ);
} else if (sel.type === 'model') {
// Модель: rootMesh.position.y = низ модели. y=0 = низ на полу.
this.selection.moveSelectedModel(sel.x, 0, sel.z);
} else if (sel.type === 'userModel') {
this.selection.moveSelectedUserModel(sel.x, 0, sel.z);
} else if (sel.type === 'primitive') {
// Центр примитива должен быть на высоте sy/2 чтобы низ касался пола.
const halfH = (sel.sy || 1) / 2;
this.selection.moveSelectedPrimitive(sel.x, halfH, sel.z);
}
}
/**
* View-preset — поставить редактор-камеру в одну из стандартных позиций.
* preset: 'top' | 'front' | 'side' | 'iso'
*/
setViewPreset(preset) {
if (!this.camera) return;
const presets = {
top: { pos: [0, 40, 0.01], rot: [Math.PI / 2, 0, 0] }, // прямо сверху
front: { pos: [0, 8, -25], rot: [0, 0, 0] }, // спереди
side: { pos: [25, 8, 0], rot: [0, -Math.PI / 2, 0] }, // сбоку
iso: { pos: [15, 15, -20], rot: [Math.PI / 5, -Math.PI / 5, 0] }, // изометрия
};
const p = presets[preset];
if (!p) return;
this.camera.position = new Vector3(p.pos[0], p.pos[1], p.pos[2]);
this.camera.rotation = new Vector3(p.rot[0], p.rot[1], p.rot[2]);
}
/**
* Поставить точку спавна там где сейчас смотрит редактор-камера
* (полезно для размещения «тут начинать игру»).
* spawnPoint = (camera.x, max(0, floor(camera.y) - 1), camera.z).
*/
setSpawnAtCamera() {
if (!this.camera) return;
const p = this.camera.position;
this._spawnPoint = {
x: Math.round(p.x),
y: Math.max(0, Math.floor(p.y) - 1),
z: Math.round(p.z),
};
this._updateSpawnMarker();
this.history?.markChange();
if (this._onSceneChange) this._onSceneChange();
}
/** Изменить позицию выделенного (используется Inspector). */
moveSelectedTo(x, y, z) {
if (!this.selection) return;
const sel = this.selection.getSelection();
if (!sel) return;
if (sel.type === 'block') {
this.selection.moveSelectedBlock(Math.round(x), Math.round(y), Math.round(z));
} else if (sel.type === 'model') {
this.selection.moveSelectedModel(x, y, z);
} else if (sel.type === 'userModel') {
this.selection.moveSelectedUserModel(x, y, z);
} else if (sel.type === 'spawn') {
this.selection.moveSelectedSpawn(x, y, z);
} else if (sel.type === 'primitive') {
this.selection.moveSelectedPrimitive(x, y, z);
}
}
/** Изменить размер выделенного примитива (Inspector). */
resizeSelectedPrimitiveTo(sx, sy, sz) {
this.selection?.resizeSelectedPrimitive(sx, sy, sz);
}
/** Изменить свойства выделенного примитива (color/material/canCollide/visible). */
setSelectedPrimitivePropsTo(patch) {
this.selection?.setSelectedPrimitiveProps(patch);
}
/** Повернуть выделенную модель (Y, в радианах). */
rotateSelectedModelTo(angleRad) {
if (!this.selection) return;
const sel = this.selection.getSelection();
if (sel?.type === 'userModel') {
this.selection.rotateSelectedUserModel(angleRad);
} else {
this.selection.rotateSelectedModel(angleRad);
}
}
/** Изменить масштаб выделенной модели. */
scaleSelectedModelTo(scale) {
if (!this.selection) return;
const sel = this.selection.getSelection();
if (sel?.type === 'userModel') {
this.selection.scaleSelectedUserModel(scale);
} else {
this.selection.scaleSelectedModel(scale);
}
}
/** Установить режим гизмо: 'select' | 'move' | 'rotate' | 'scale'. */
setGizmoMode(mode) {
if (this._gizmo) this._gizmo.setMode(mode);
}
/** Получить текущий режим гизмо. */
getGizmoMode() {
return this._gizmo ? this._gizmo.getMode() : 'select';
}
/** Установить snap-step гизмо для перемещения (1.0 / 0.5 / 0.25 / 0=off).
* Также применяется к Inspector-вводу координат моделей. */
setGizmoSnap(step) {
if (this._gizmo) this._gizmo.setSnap(step);
if (this.selection) this.selection.setSnapStep(step);
}
getGizmoSnap() {
return this._gizmo ? this._gizmo.getSnap() : 0;
}
/** Сфокусировать редактор-камеру на выделенном (двигает камеру к объекту). */
focusOnSelection() {
const sel = this.selection?.getSelection();
if (!sel) return;
let target;
if (sel.type === 'block') {
target = new Vector3(sel.gridX, sel.gridY + 0.5, sel.gridZ);
} else if (sel.type === 'model' || sel.type === 'spawn' || sel.type === 'primitive') {
target = new Vector3(sel.x, sel.y + 0.5, sel.z);
}
if (target) this._focusOnTarget(target);
}
/** Установить точку спавна игрока в режиме Play. */
setSpawnPoint(x, y, z) {
this._spawnPoint = { x, y, z };
this._updateSpawnMarker();
}
/** Установить тип модели персонажа (для Play). */
setPlayerModelType(typeId) {
if (!typeId) return;
this._playerModelType = typeId;
}
getPlayerModelType() {
return this._playerModelType;
}
/** Идёт ли сейчас режим игры. */
isPlaying() {
return this._isPlaying;
}
/**
* Переключить в режим игры. Создаём PlayerController, прячем ghost-блок,
* запоминаем позицию редактор-камеры чтобы вернуть при exit.
*/
enterPlayMode() {
if (this._isPlaying) return;
this._isPlaying = true;
// Сброс состояния касаний — каждый прогон начинается «не касаясь»,
// иначе rising-edge touch не сработает, если при стопе игрок стоял на цели.
if (this._touchState) this._touchState.clear();
// По умолчанию стандартный HUD видим в Play.
// Скрипт может скрыть через game.hud.setVisible(false).
this._setStdHudVisible(true);
this._setHotbarVisible(true);
this._setHpVisible(true);
// Включаем picking voxel-террейна — иначе камера _clampCameraToWorld
// не «видит» воксели в Ray-каст и пролетает сквозь стены.
// В редакторе остаётся выключенным (быстрее).
try { this.terrainManager?.enablePickingForCamera?.(true); } catch (e) {}
// Снимок редактор-камеры
this._editorCameraSnapshot = {
position: this.camera.position.clone(),
rotation: this.camera.rotation.clone(),
};
if (this._ghostMesh) this._ghostMesh.setEnabled(false);
this._setSpawnMarkerVisible(false);
// Триггеры — невидимые в Play, видимые в редакторе
this.primitiveManager?.setTriggersVisible(false);
// Запоминаем исходные позиции unanchored-объектов чтобы вернуть
// их при выходе из Play (физика двигает mesh.position).
this._snapshotDynamicObjects();
// Полный снимок примитивов и моделей — чтобы при Stop откатить
// ВСЕ изменения скриптов (удаления, цвет, видимость, повороты).
this._snapshotFullScene();
// Запускаем физику unanchored
this.dynamics?.start();
// Запускаем фоновую музыку и амбиент
this.audioManager?.start();
// Создаём PlayerController и стартуем
this.player = new PlayerController(this.scene, this.canvas, this.physics, this);
this.player.setModelType(this._playerModelType);
// Задача 04: модал нуждается в player и audio для блока ввода/freeze камеры/duck
try {
this.modalManager?.attachPlayer?.(this.player);
this.modalManager?.attachAudio?.(this.audioManager);
} catch (e) {}
this.player._jumpPowerMul = this._jumpPowerMul ?? 1;
// Применяем дефолтную камеру если задана в сцене
if (this._defaultCameraMode && this._defaultCameraMode !== this.player._cameraMode) {
this.player._cameraMode = this._defaultCameraMode;
}
// На тач-устройствах отключаем pointer-lock и mouse-камеру
if (this._touchMode) this.player.setTouchMode(true);
this.player.setOnExitRequest(() => {
// Задача 07: магазин скинов открыт → Esc закрывает его (приоритет выше модала).
if (this._skinShop?.open) {
this._closeSkinShop();
return;
}
// Задача 04: если открыт модал — первый Esc закрывает его,
// второй Esc уже выходит из Play. Так юзер не теряет состояние игры
// случайно при попытке скрыть модал.
if (this.modalManager?.isOpen?.()) {
this.modalManager.close();
return;
}
this.exitPlayMode();
if (this._onPlayChange) this._onPlayChange(false);
});
if (this._onPlayerHpChange) this.player.setOnHpChange(this._onPlayerHpChange);
if (this._onPlayerDeath) this.player.setOnDeath(this._onPlayerDeath);
this.player.start(this._spawnPoint);
// Запускаем пользовательские скрипты (этап 2.1).
// Async PlayerController.start даёт нам тик чтобы дать ему собрать сцену,
// поэтому скрипты стартуем в следующем кадре.
this.gameRuntime = new GameRuntime(this);
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
// Аудио-менеджер (GD-музыка/SFX) — создаётся вместе с runtime.
// ВАЖНО: поле называется gameAudioManager чтобы не путать со штатным
// this.audioManager (AudioManager — ambient/music для всех проектов).
if (!this.gameAudioManager) {
this.gameAudioManager = new GameAudioManager();
}
// GD-уровень (Этап 5.1): автоматически обрабатывает GD-порталы, шипы, финиш, монеты.
// Юзер просто ставит объекты из палитры (категории "GD-порталы" и "GD-объекты") в редакторе.
if (!this.gdLevelManager) {
this.gdLevelManager = new GdLevelManager(this);
this.gdLevelManager.setOnPortalEnter((newMode, prevMode) => {
try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdPortalEnter', data: { newMode, prevMode } }); } catch (e) {}
try { this.gameAudioManager?.playSfx?.('flip'); } catch (e) {}
});
this.gdLevelManager.setOnDeath((info) => {
try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdDeath', data: info }); } catch (e) {}
try { this.gameAudioManager?.playSfx?.('death'); } catch (e) {}
// Респавн игрока через teleport на spawnPoint
try {
const sp = this._spawnPoint || { x: 0, y: 2, z: 0 };
this.player.teleport(sp.x, sp.y, sp.z);
// Сбросить vy чтобы не нести инерцию из шипа
if (this.player) this.player._vy = 0;
} catch (e) {}
});
this.gdLevelManager.setOnFinish((info) => {
const stats = this.gdLevelManager.getCoinsStats();
try {
this.gameRuntime?.routeGlobalEvent?.('message', {
name: 'gdFinish',
data: { ...info, coinsCollected: stats.collected, coinsTotal: stats.total },
});
} catch (e) {}
try { this.gameAudioManager?.playSfx?.('level_complete'); } catch (e) {}
});
this.gdLevelManager.setOnCoinCollected((info) => {
try { this.gameRuntime?.routeGlobalEvent?.('message', { name: 'gdCoinCollected', data: info }); } catch (e) {}
try { this.gameAudioManager?.playSfx?.('coin'); } catch (e) {}
});
}
this.gdLevelManager.start();
// Этапы G1/G2: skybox+параллакс+декоративная трава для GD-уровней.
// Откладываем на setTimeout — primitiveManager.instances наполняется
// не сразу при enterPlayMode (load.primitives асинхронный).
// GD-проект определяется флагом settings.isGd (см. serialize/loadFromState).
// Fallback для старых проектов БЕЗ флага — реальные id GD-уровней:
// - 295: GD 2.0 sandbox
// - 296..306: L1-L11 (эпоха 1 + L11 легаси)
// - 350..358: L12-L20 (эпоха 2)
// Раньше было только 296..315 — L12-L20 (id 350..358) НЕ попадали,
// и веб-плеер не активировал GD-инфру (шипы конусы вместо .glb,
// нет skybox/forest на заднем фоне). На APK работало правильно.
const _pid = Number(this._currentProjectId);
const isGd = (typeof this._isGdProject === 'boolean')
? this._isGdProject
: ((_pid >= 296 && _pid <= 315)
|| (_pid >= 350 && _pid <= 358)
|| _pid === 295);
console.log(`[GD-gfx] currentProjectId=${this._currentProjectId}, isGd=${isGd}, flag=${this._isGdProject}`);
if (isGd) {
// Ширина уровня — по самому правому cube-блоку
let levelWidth = 1000;
if (this.blockManager && this.blockManager.blocks) {
for (const key of this.blockManager.blocks.keys()) {
const x = parseInt(String(key).split(',')[0], 10);
if (Number.isFinite(x) && x > levelWidth) levelWidth = x;
}
}
setTimeout(() => {
try {
if (!this.gdSkybox) {
this.gdSkybox = new GdSkybox();
const cam = this.player?.camera || this.scene.activeCamera;
this.gdSkybox.attach(this.scene, cam);
console.log('[GD-gfx] skybox attached');
}
if (!this.gdGroundSkin) {
this.gdGroundSkin = new GdGroundSkin();
this.gdGroundSkin.attach(this.scene, levelWidth, this._shadowGenerator, this);
console.log('[GD-gfx] groundSkin attached, width=', levelWidth);
}
// Эпоха по project_id. L11 = 306 (легаси). L12-L20 = 350-358.
const pid = Number(this._currentProjectId) || 296;
const GD_PID_TO_EPOCH = {
296:1, 297:1, 298:1, 299:1, 300:1, 301:1, 302:1, 303:1, 304:1, 305:1,
306:2, 350:2, 351:2, 352:2, 353:2, 354:2, 355:2, 356:2, 357:2, 358:2,
};
const epoch = GD_PID_TO_EPOCH[pid] || 1;
if (!this.gdSpikes) {
this.gdSpikes = new GdSpikes();
this.gdSpikes.attach(this.scene, this, epoch);
}
if (!this.gdStartArch) {
this.gdStartArch = new GdStartArch();
this.gdStartArch.attach(this.scene, epoch);
}
if (!this.gdPortalArch) {
this.gdPortalArch = new GdPortalArch();
this.gdPortalArch.attach(this.scene, this, this._currentUserId);
}
if (!this.gdDiamond) {
this.gdDiamond = new GdDiamond();
this.gdDiamond.attach(this.scene, this);
}
if (!this.gdFinish) {
this.gdFinish = new GdFinish();
this.gdFinish.attach(this.scene, this, epoch);
}
if (!this.gdForest) {
this.gdForest = new GdForest();
this.gdForest.attach(this.scene, levelWidth, epoch);
}
if (!this.gdPlayerCube) {
this.gdPlayerCube = new GdPlayerCube();
this.gdPlayerCube.attach(this.scene, this);
}
if (!this.gdPlayerModeSkin) {
// Задержка 600мс — даём скрипту уровня применить базовый cube-skin,
// чтобы _origTexture при первой смене режима содержала правильную текстуру.
setTimeout(() => {
this.gdPlayerModeSkin = new GdPlayerModeSkin();
this.gdPlayerModeSkin.attach(this.scene, this, this._currentUserId);
}, 600);
}
if (!this.gdPlayerTrail) {
this.gdPlayerTrail = new GdPlayerTrail();
this.gdPlayerTrail.attach(this.scene, this, this._currentProjectId, this._currentUserId);
}
if (!this.gdPostFx) {
this.gdPostFx = new GdPostFx();
const cam = this.player?.camera || this.scene.activeCamera;
this.gdPostFx.attach(this.scene, cam, this);
}
// Тени отключены — делаем через GdGroundSkin (fake shadows)
this._enableGdShadows();
} catch (e) { console.warn('[BabylonScene] GD-graphics attach failed', e); }
}, 50);
}
if (this._onScriptLog) this.gameRuntime.setOnLog(this._onScriptLog);
if (this._onScriptHud) this.gameRuntime.setOnHud(this._onScriptHud);
if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair);
// eslint-disable-next-line no-console
console.log('[BabylonScene] enterPlayMode — scripts to run:', this._scripts?.length || 0, this._scripts);
// Старт через requestAnimationFrame — даём Babylon собрать сцену
requestAnimationFrame(() => {
if (this._isPlaying) this.gameRuntime?.start(this._scripts || []);
});
// === Оружие ===
if (!this.weapons) this.weapons = new WeaponSystem(this);
if (this._onAmmoChange) this.weapons.setOnAmmoChange(this._onAmmoChange);
// Подключаем зомби-логику к попаданиям пули
this.weapons.setOnHit((hit) => {
if (hit?.mesh && this.zombieManager) {
this.zombieManager.damageByMesh(hit.mesh, hit.damage || 25);
}
if (this._onWeaponHit) {
try { this._onWeaponHit(hit); } catch (e) {}
}
});
this.weapons.start();
// === Зомби-система ===
if (!this.zombieManager) this.zombieManager = new ZombieManager(this);
if (!this.spawnerManager) this.spawnerManager = new ZombieSpawnerManager(this, this.zombieManager);
this.zombieManager.start();
this.spawnerManager.start();
// === NPC-система (Фаза 4.1) — управляемые скриптом персонажи ===
if (!this.npcManager) this.npcManager = new NpcManager(this);
this.npcManager.start();
// === Связи объектов (Фаза 5, Constraints) ===
if (!this.constraintManager) this.constraintManager = new ConstraintManager(this);
this.constraintManager.start();
// === Лучи и следы (Фаза 5.2 — Beam/Trail) ===
if (!this.beamManager) this.beamManager = new BeamManager(this);
this.beamManager.start();
// Задача 08: активируем pointer-примитивы из палитры в реальные стрелки.
this._activatePointers();
// === 3D-звук (Фаза 5.5 — позиционный звук) ===
if (!this.soundManager) this.soundManager = new SoundManager(this);
this.soundManager.start();
// Регистрируем gameplay-объекты: зомби и спавнеры.
// Применяем defaults + текущие gameplayParams из инспектора.
if (this.modelManager) {
for (const data of this.modelManager.instances.values()) {
const gp = data.gameplay;
if (!gp) continue;
const params = { ...(gp.defaultParams || {}), ...(data.gameplayParams || {}) };
if (gp.isZombie) {
this.zombieManager.registerExisting(data.instanceId, params);
} else if (gp.isZombieSpawner) {
this.spawnerManager.register(data.instanceId, params);
}
}
}
// Снаряжаем оружие из активного слота инвентаря
const active = this.inventory?.getActive?.();
if (active) this.weapons.equip(active);
// Замораживаем world-matrix у всех статичных GLB-моделей
// (не зомби и не спавнеры). Деревья, дома, камни не двигаются —
// Babylon не должен пересчитывать их матрицы каждый кадр.
try { this.modelManager?.freezeStaticModels?.(); } catch (e) {}
try { this.primitiveManager?.freezeStaticPrimitives?.(); } catch (e) {}
// ОПТИМИЗАЦИЯ ОТКЛЮЧЕНА: octree селекшн.
// Octree создаётся один раз и не «знает» о мешах добавленных позже —
// даже с alwaysSelectAsActiveMesh новые меши (трейсеры выстрелов,
// debris-кубы при смерти, динамические объекты) фактически выпадают
// из активного списка → невидимы. Стандартный frustum-culling Babylon
// дешёвый сам по себе для нашей сцены, octree больше вреда чем пользы.
}
/** Заглушка для совместимости — раньше пересоздавала octree. */
setActiveMeshesDirty() {
// no-op
}
/** Установить колбэк логов от скриптов (для Console-панели UI). */
setOnScriptLog(cb) {
this._onScriptLog = cb;
if (this.gameRuntime) this.gameRuntime.setOnLog(cb);
}
/** Колбэк команд HUD от скриптов (для GameHud React-компонента). */
setOnScriptHud(cb) {
this._onScriptHud = cb;
if (this.gameRuntime) this.gameRuntime.setOnHud(cb);
}
/** Колбэк смены прицела из скрипта (game.player.crosshair = 'cross'). */
setOnScriptCrosshair(cb) {
this._onScriptCrosshair = cb;
if (this.gameRuntime) this.gameRuntime.setOnCrosshairChange(cb);
}
// ============================================================
// Таймер прохождения (для лидерборда)
// ============================================================
/** cb({state: 'start'|'stop'|'submit', timeMs}) */
setOnTimer(cb) { this._onTimer = cb; }
_timerStart() {
this._timerStartedAt = performance.now();
this._timerRunning = true;
if (this._onTimer) try { this._onTimer({ state: 'start', timeMs: 0 }); } catch (e) {}
}
_timerStop() {
if (!this._timerRunning) return;
const ms = Math.round(performance.now() - (this._timerStartedAt || 0));
this._timerRunning = false;
if (this._onTimer) try { this._onTimer({ state: 'stop', timeMs: ms }); } catch (e) {}
}
_timerSubmit() {
if (!this._timerRunning && !this._timerStartedAt) return;
const ms = Math.round(performance.now() - (this._timerStartedAt || 0));
this._timerRunning = false;
if (this._onTimer) try { this._onTimer({ state: 'submit', timeMs: ms }); } catch (e) {}
}
/** Получить текущее время таймера в мс (или 0 если не запущен). */
getTimerMs() {
if (!this._timerRunning || !this._timerStartedAt) return 0;
return Math.round(performance.now() - this._timerStartedAt);
}
isTimerRunning() { return !!this._timerRunning; }
/** PERF-METRICS: получить и сбросить накопленные метрики за окно. */
flushPerfMetrics() {
const m = this._perfMetrics;
if (!m) return null;
const out = {
render_ms_avg: m.render_count ? (m.render_ms_sum / m.render_count) : 0,
physics_ms_avg: m.physics_count ? (m.physics_ms_sum / m.physics_count) : 0,
script_ms_avg: m.script_count ? (m.script_ms_sum / m.script_count) : 0,
idle_ms_avg: m.idle_count ? (m.idle_ms_sum / m.idle_count) : 0,
render_count: m.render_count,
physics_count: m.physics_count,
script_count: m.script_count,
};
m.render_ms_sum = 0; m.render_count = 0;
m.physics_ms_sum = 0; m.physics_count = 0;
m.script_ms_sum = 0; m.script_count = 0;
m.idle_ms_sum = 0; m.idle_count = 0;
return out;
}
/**
* Поставить render-loop на паузу.
* Используется когда Babylon canvas не виден (активен таб скрипта),
* чтобы освободить CPU/GPU и Monaco не лагал.
* НЕ останавливает Play-режим — только рендер.
*/
pauseRendering() { this._renderingPaused = true; }
resumeRendering() { this._renderingPaused = false; }
isRenderingPaused() { return !!this._renderingPaused; }
/**
* Создать эффект частиц в точке. Вызывается из GameRuntime._spawnParticles
* (через game.scene.spawnParticles в скриптах).
*
* payload: { type, position: {x,y,z}, duration, count, color }
*/
_spawnParticleEffect(payload) {
if (!payload || !this.scene) return;
const pos = payload.position || { x: 0, y: 0, z: 0 };
const type = payload.type || 'sparks';
const duration = Math.max(0.1, Math.min(20, Number(payload.duration) || 1.5));
const countMul = Math.max(0.1, Math.min(10, Number(payload.count) || 1));
// Кэшируем текстуру частицы — один раз на сцену
if (!this._particleTex) {
const tex = new DynamicTexture('particleTex', { width: 64, height: 64 }, this.scene, false);
const ctx = tex.getContext();
const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
grad.addColorStop(0, 'rgba(255,255,255,1)');
grad.addColorStop(0.4, 'rgba(255,255,255,0.6)');
grad.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 64, 64);
tex.update();
tex.hasAlpha = true;
this._particleTex = tex;
}
// MOBILE-OPT (этап 1): на мобильном уменьшаем кол-во частиц в 2 раза
const baseCount = this._isMobileMode ? 40 : 80;
const ps = new ParticleSystem('p_' + Date.now(),
Math.floor(baseCount * countMul), this.scene);
ps.particleTexture = this._particleTex;
ps.emitter = new Vector3(pos.x, pos.y, pos.z);
ps.minEmitBox = new Vector3(-0.1, -0.1, -0.1);
ps.maxEmitBox = new Vector3(0.1, 0.1, 0.1);
ps.blendMode = ParticleSystem.BLENDMODE_ADD;
const customColor = payload.color && /^#[0-9a-fA-F]{6}$/.test(payload.color)
? payload.color : null;
this._configureParticleSystem(ps, type, customColor, countMul);
ps.start();
// Авто-стоп: для explosion почти сразу (это burst), для остальных = duration
const stopAt = type === 'explosion' ? 0.05 : duration;
const disposeAt = stopAt + (ps.maxLifeTime || 1) + 0.3;
setTimeout(() => { try { ps.stop(); } catch (e) {} }, stopAt * 1000);
// dispose(false) — particleTexture расшарена (_particleTex), не удалять.
setTimeout(() => { try { ps.dispose(false); } catch (e) {} }, disposeAt * 1000);
}
/**
* Настроить параметры ParticleSystem под тип эффекта.
* Общий конфигуратор для разового эффекта (_spawnParticleEffect) и
* постоянного эмиттера-объекта (createEmitterParticles).
*/
_configureParticleSystem(ps, type, customColor, countMul = 1) {
const hexToColor4 = (hex, a = 1) => {
const r = parseInt(hex.substr(1, 2), 16) / 255;
const g = parseInt(hex.substr(3, 2), 16) / 255;
const b = parseInt(hex.substr(5, 2), 16) / 255;
return new Color4(r, g, b, a);
};
switch (type) {
case 'fire':
ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.6, 0.1, 1);
ps.color2 = customColor ? hexToColor4(customColor, 0.8) : new Color4(1, 0.2, 0, 1);
ps.colorDead = new Color4(0.2, 0, 0, 0);
ps.minSize = 0.2; ps.maxSize = 0.5;
ps.minLifeTime = 0.4; ps.maxLifeTime = 1.0;
ps.emitRate = 80;
ps.gravity = new Vector3(0, 1.2, 0);
ps.direction1 = new Vector3(-0.4, 1.5, -0.4);
ps.direction2 = new Vector3(0.4, 2.0, 0.4);
ps.minEmitPower = 0.5; ps.maxEmitPower = 1.2;
break;
case 'smoke':
ps.color1 = new Color4(0.4, 0.4, 0.4, 0.6);
ps.color2 = new Color4(0.2, 0.2, 0.2, 0.4);
ps.colorDead = new Color4(0, 0, 0, 0);
ps.minSize = 0.4; ps.maxSize = 1.2;
ps.minLifeTime = 1.5; ps.maxLifeTime = 3;
ps.emitRate = 40;
ps.gravity = new Vector3(0, 0.5, 0);
ps.direction1 = new Vector3(-0.3, 1, -0.3);
ps.direction2 = new Vector3(0.3, 1.5, 0.3);
ps.minEmitPower = 0.3; ps.maxEmitPower = 0.7;
ps.blendMode = ParticleSystem.BLENDMODE_STANDARD;
break;
case 'sparks':
ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 1, 0.4, 1);
ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.6, 0, 1);
ps.colorDead = new Color4(0.5, 0.2, 0, 0);
ps.minSize = 0.05; ps.maxSize = 0.15;
ps.minLifeTime = 0.3; ps.maxLifeTime = 0.8;
ps.emitRate = 200;
ps.gravity = new Vector3(0, -8, 0);
ps.direction1 = new Vector3(-3, 4, -3);
ps.direction2 = new Vector3(3, 7, 3);
ps.minEmitPower = 1; ps.maxEmitPower = 3;
break;
case 'magic':
ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(0.6, 0.3, 1, 1);
ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(0.3, 0.6, 1, 1);
ps.colorDead = new Color4(0.2, 0, 0.5, 0);
ps.minSize = 0.15; ps.maxSize = 0.35;
ps.minLifeTime = 1; ps.maxLifeTime = 2.2;
ps.emitRate = 60;
ps.gravity = new Vector3(0, 0.3, 0);
ps.direction1 = new Vector3(-1, 1, -1);
ps.direction2 = new Vector3(1, 2, 1);
ps.minEmitPower = 0.5; ps.maxEmitPower = 1.5;
break;
case 'explosion':
ps.color1 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.7, 0.2, 1);
ps.color2 = customColor ? hexToColor4(customColor, 1) : new Color4(1, 0.3, 0, 1);
ps.colorDead = new Color4(0.2, 0, 0, 0);
ps.minSize = 0.3; ps.maxSize = 0.8;
ps.minLifeTime = 0.4; ps.maxLifeTime = 1.0;
ps.emitRate = 0;
ps.manualEmitCount = Math.floor(120 * countMul);
ps.gravity = new Vector3(0, -3, 0);
ps.direction1 = new Vector3(-5, -1, -5);
ps.direction2 = new Vector3(5, 5, 5);
ps.minEmitPower = 2; ps.maxEmitPower = 6;
break;
case 'confetti':
ps.color1 = new Color4(1, 0.3, 0.3, 1);
ps.color2 = new Color4(0.3, 0.6, 1, 1);
ps.colorDead = new Color4(0, 0, 0, 0);
ps.minSize = 0.1; ps.maxSize = 0.25;
ps.minLifeTime = 1.5; ps.maxLifeTime = 3;
ps.emitRate = 100;
ps.gravity = new Vector3(0, -3, 0);
ps.direction1 = new Vector3(-3, 5, -3);
ps.direction2 = new Vector3(3, 8, 3);
ps.minEmitPower = 1; ps.maxEmitPower = 3;
break;
default:
ps.color1 = new Color4(1, 1, 1, 1);
ps.color2 = new Color4(0.7, 0.7, 0.7, 0.5);
ps.colorDead = new Color4(0, 0, 0, 0);
ps.minSize = 0.1; ps.maxSize = 0.3;
ps.minLifeTime = 0.5; ps.maxLifeTime = 1.5;
ps.emitRate = 60;
ps.direction1 = new Vector3(-1, 1, -1);
ps.direction2 = new Vector3(1, 2, 1);
}
}
/**
* Создать ПОСТОЯННУЮ систему частиц для эмиттера-объекта (костёр и т.п.).
* Не имеет авто-стопа — горит пока объект существует. Возвращает ps.
*/
createEmitterParticles(type, position, color) {
if (!this.scene) return null;
if (!this._particleTex) {
const tex = new DynamicTexture('particleTex', { width: 64, height: 64 }, this.scene, false);
const ctx = tex.getContext();
const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
grad.addColorStop(0, 'rgba(255,255,255,1)');
grad.addColorStop(0.4, 'rgba(255,255,255,0.6)');
grad.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 64, 64);
tex.update();
tex.hasAlpha = true;
this._particleTex = tex;
}
const baseCount = this._isMobileMode ? 60 : 120;
const ps = new ParticleSystem('emitter_' + Date.now(), baseCount, this.scene);
ps.particleTexture = this._particleTex;
ps.emitter = new Vector3(position.x, position.y, position.z);
ps.minEmitBox = new Vector3(-0.15, -0.1, -0.15);
ps.maxEmitBox = new Vector3(0.15, 0.1, 0.15);
ps.blendMode = ParticleSystem.BLENDMODE_ADD;
const customColor = color && /^#[0-9a-fA-F]{6}$/.test(color) ? color : null;
// explosion как постоянный эффект не имеет смысла → fire
const effType = type === 'explosion' ? 'fire' : type;
this._configureParticleSystem(ps, effType, customColor, 1);
ps.start();
return ps;
}
/**
* Запустить ОДИН скрипт без Play-режима (отладочный запуск из редактора).
* Если runtime уже есть — переиспользуем, иначе создаём.
*/
startSoloScript(scriptId) {
const all = this._scripts || [];
const sc = all.find(s => s.id === scriptId);
if (!sc) return false;
if (!this.gameRuntime) {
this.gameRuntime = new GameRuntime(this);
try { this.modalManager?.attachRuntime?.(this.gameRuntime); } catch (e) {}
if (!this.gameAudioManager) {
this.gameAudioManager = new GameAudioManager();
}
if (this._onScriptLog) this.gameRuntime.setOnLog(this._onScriptLog);
if (this._onScriptHud) this.gameRuntime.setOnHud(this._onScriptHud);
if (this._onScriptCrosshair) this.gameRuntime.setOnCrosshairChange(this._onScriptCrosshair);
}
this.gameRuntime.startSolo(sc);
return true;
}
/** Остановить отладочный (solo) запуск. */
stopSoloScript() {
if (this.gameRuntime && this.gameRuntime.isSolo?.()) {
this.gameRuntime.stop();
// Если не в Play-режиме — освобождаем runtime
if (!this._isPlaying) {
this.gameRuntime = null;
}
}
}
isSoloRunning() {
return !!this.gameRuntime?.isSolo?.();
}
getSoloScriptId() {
return this.gameRuntime?.getSoloScriptId?.() || null;
}
/** Получить все скрипты проекта. */
getScripts() { return [...this._scripts]; }
/** Заменить все скрипты (используется при load/edit). */
setScripts(scripts) {
this._scripts = Array.isArray(scripts) ? scripts.slice() : [];
}
/** Установить код одного скрипта по id. Если id нет — создать новый. */
upsertScript(id, code, target = undefined) {
const i = this._scripts.findIndex(s => s.id === id);
if (i >= 0) {
this._scripts[i] = {
...this._scripts[i],
code,
...(target !== undefined ? { target } : {}),
};
} else {
this._scripts.push({
id: id || `script_${Date.now()}`,
code,
target: target !== undefined ? target : null,
});
}
// Скрипты — часть сцены: фиксируем в истории, иначе undo откатит
// _scripts к снапшоту, снятому до создания скрипта, и скрипт пропадёт.
this.history?.markChange();
if (this._onSceneChange) this._onSceneChange();
}
/** Удалить скрипт по id. */
removeScript(id) {
this._scripts = this._scripts.filter(s => s.id !== id);
this.history?.markChange();
if (this._onSceneChange) this._onSceneChange();
}
/**
* Зарегистрировать колбэк для уведомлений об изменении режима Play
* (вызывается когда player сам инициирует exit, например по Esc).
* KubikonEditor подписывается чтобы синхронизировать React-state.
*/
setOnPlayChange(cb) {
this._onPlayChange = cb;
}
/**
* Колбэк изменения сцены (любая модификация блоков/моделей).
* Используется KubikonEditor для dirty-tracking → auto-save.
* Сами обработчики на blockManager/modelManager привязаны в init() —
* они дёргают и history.markChange() и this._onSceneChange.
*/
setOnSceneChange(cb) {
this._onSceneChange = cb;
}
/** Колбэк изменения GUI-элементов (для перерисовки React-overlay). */
setOnGuiChange(cb) {
this._onGuiChange = cb;
}
/** Подключить API для пользовательских моделей (Kubikon3DService).
* Нужно дважды: setApi для самого UserModelManager (getUserModel)
* и сохранить _userModelsApi для incrementModelUses в _handlePlaceModel. */
setUserModelsApi(api) {
this._userModelsApi = api;
if (this.userModelManager && api) {
this.userModelManager.setApi(api);
}
}
/** Передать id текущего пользователя — для запросов к приватным моделям. */
setCurrentUserId(userId) {
this._currentUserId = userId;
}
/** Передать id текущего проекта — для game.save.* эндпоинтов (savegame API).
* Без этого скрипты не смогут сохранять прогресс. */
setCurrentProjectId(projectId) {
this._currentProjectId = projectId;
}
/** Колбэк изменения видимости стандартного HUD (HP-бар, hotbar, ...).
* Редактор/плеер подписываются и реактивно скрывают/показывают элементы.
* Скрипт зовёт game.hud.setVisible(false) → этот колбэк сработает. */
setOnStdHudVisibilityChange(cb) {
this._onStdHudVisibilityChange = cb;
}
_setStdHudVisible(visible) {
this._stdHudVisible = !!visible;
try { this._onStdHudVisibilityChange?.(this._stdHudVisible); } catch (e) {}
}
/** Задача 03: отдельный контроль хотбара (5 слотов инвентаря снизу).
* Дёргается из game.hud.setHotbarVisible(bool). */
setOnHotbarVisibilityChange(cb) {
this._onHotbarVisibilityChange = cb;
}
_setHotbarVisible(visible) {
this._hotbarVisible = !!visible;
try { this._onHotbarVisibilityChange?.(this._hotbarVisible); } catch (e) {}
}
/** Задача 03: отдельный контроль HP-индикатора (полоска слева сверху).
* Дёргается из game.hud.setHpVisible(bool). */
setOnHpVisibilityChange(cb) {
this._onHpVisibilityChange = cb;
}
_setHpVisible(visible) {
this._hpVisible = !!visible;
try { this._onHpVisibilityChange?.(this._hpVisible); } catch (e) {}
}
/** Колбэк смены cursor-режима (ui/game) скриптом через game.input.setCursorMode.
* Редактор подписан чтобы синхронизировать React-state uiCursorMode (для бейджа). */
setOnCursorModeChange(cb) {
this._onCursorModeChange = cb;
}
/** Пересобрать spatial-индекс физики для user-моделей.
* Вызывается из SelectionManager при изменении canCollide / anchored /
* position / rotation / scale. */
_syncUserModelColliders() {
try { this.physics?.setSpatialDirty?.(); } catch (e) {}
}
/** Инвалидировать пользовательскую модель после её редактирования.
* Сбрасывает кэш + пересоздаёт все инстансы этой модели в сцене с
* новой геометрией. Вызывается из KubikonEditor.jsx после закрытия
* редактора модели (когда editingUserModelId != null). */
async refreshUserModel(userModelId) {
if (!this.userModelManager) return 0;
const rebuilt = await this.userModelManager.invalidateModel(userModelId, {
rebuild: true,
currentUserId: this._currentUserId || null,
});
// Тени для свежесозданных мешей
if (rebuilt > 0) {
for (const inst of this.userModelManager.instances.values()) {
if (inst.userModelId === userModelId) {
for (const m of inst.meshes) {
try { this.addShadowCaster(m); } catch (e) {}
}
}
}
this._syncUserModelColliders();
}
return rebuilt;
}
/** Колбэк изменения инвентаря (для hot-bar React). */
setOnInventoryChange(cb) {
this._onInventoryChange = cb;
}
/** Колбэк изменения патронов оружия (для GUI). */
setOnAmmoChange(cb) {
this._onAmmoChange = cb;
if (this.weapons) this.weapons.setOnAmmoChange(cb);
}
/** Колбэк попадания пули (для логики урона зомби и др.). */
setOnWeaponHit(cb) {
this._onWeaponHit = cb;
if (this.weapons) this.weapons.setOnHit(cb);
}
/** Колбэк изменения HP игрока. */
setOnPlayerHpChange(cb) {
this._onPlayerHpChange = cb;
if (this.player) this.player.setOnHpChange(cb);
}
/** Колбэк смерти игрока. */
setOnPlayerDeath(cb) {
this._onPlayerDeath = cb;
if (this.player) this.player.setOnDeath(cb);
}
/** Колбэк Escape в редакторе (для возврата в инструмент «Выделить»). */
setOnEditorEscape(cb) {
this._onEditorEscape = cb;
}
getInventoryState() {
return this.inventory ? this.inventory.serialize() : { slots: [], activeIndex: 0 };
}
setActiveInventorySlot(index) {
// Phase 6.4: фиксируем что было активно ДО смены -- для onUnequipped.
const wasActive = this.inventory?.getActive?.();
this.inventory?.setActive(index);
// Если в Play — пересменяем оружие
if (this._isPlaying && this.weapons) {
const active = this.inventory?.getActive?.();
if (active && active.kind === 'weapon') {
this.weapons.equip(active);
} else {
this.weapons.unequip();
}
// Сообщаем мультиплееру о смене оружия — чтобы remote-клиенты
// увидели в руке нашей модели правильный GLB.
if (this._mpSync) {
const modelId = (active && active.kind === 'weapon')
? (active.modelTypeId || '')
: '';
try { this._mpSync.sendWeapon(modelId); } catch (e) {}
}
// Phase 6.4: события onEquipped / onUnequipped для кастомных tool.
try {
if (wasActive && wasActive !== active) {
const tool = {
kind: wasActive.kind, modelTypeId: wasActive.modelTypeId, name: wasActive.name,
};
if (wasActive.params && wasActive.params._customToolId) {
tool.customToolId = wasActive.params._customToolId;
}
this.gameRuntime?.routeGlobalEvent?.('toolUnequipped', { tool });
}
if (active && active !== wasActive) {
const tool = {
kind: active.kind, modelTypeId: active.modelTypeId, name: active.name,
};
if (active.params && active.params._customToolId) {
tool.customToolId = active.params._customToolId;
}
this.gameRuntime?.routeGlobalEvent?.('toolEquipped', { tool });
}
} catch (e) { /* ignore */ }
}
}
addInventoryItem(item) {
return this.inventory?.add(item) ?? -1;
}
/** API для UI: создать/изменить/удалить GUI-элемент. Делегирует в GuiManager. */
createGuiElement(type, opts) {
return this.guiManager?.create(type, opts);
}
updateGuiElement(id, patch) {
this.guiManager?.update(id, patch);
}
removeGuiElement(id) {
// Если был выделен — снять выделение
if (this.selection?._selection?.type === 'gui' && this.selection._selection.id === id) {
this.selection.clearSelection?.();
}
this.guiManager?.remove(id);
}
renameGuiElement(id, name) {
this.guiManager?.rename(id, name);
}
moveGuiElementZ(id, direction) {
this.guiManager?.moveZ(id, direction);
}
getGuiElements() {
return this.guiManager ? this.guiManager.getAll() : [];
}
// ===== Задача 07: встроенный магазин скинов (React-оверлей) =====
// Состояние держим тут; React-компонент SkinShopOverlay поллит getSkinShopState().
_ensureSkinShopState() {
if (!this._skinShop) {
this._skinShop = {
open: false,
rev: 0, // ревизия — React видит изменение
data: { all: [], unlocked: [], current: null, coins: 0, manifestFull: [] },
buyResult: null, // последний результат покупки {slug, ok, reason}
};
}
return this._skinShop;
}
/** Снимок состояния магазина для React (поллинг через rAF). */
getSkinShopState() { return this._skinShop || null; }
/** Открыть/закрыть магазин (из скрипта или клавиши B). */
_openSkinShop() {
const s = this._ensureSkinShopState();
// Отключён в проекте? (скрипт всё равно может открыть через API —
// shopVisible:false запрещает только клавишу B, см. toggleSkinShop).
s.open = true; s.rev++;
}
_closeSkinShop() {
const s = this._ensureSkinShopState();
s.open = false; s.rev++;
}
toggleSkinShop() {
const s = this._ensureSkinShopState();
if (s.open) { this._closeSkinShop(); return; }
// Клавиша B открывает магазин только если он включён в проекте.
if (this._skinsConfig && this._skinsConfig.shopVisible === false) return;
this._openSkinShop();
}
/** Данные скинов от GameRuntime (манифест + unlocked + coins). */
_setSkinShopData(data) {
const s = this._ensureSkinShopState();
s.data = { ...s.data, ...data };
s.rev++;
}
_onSkinBuyResult(res) {
const s = this._ensureSkinShopState();
s.buyResult = { ...res, t: (this.scene?.getEngine?.()?.getFps?.() || 0) };
s.rev++;
}
/** Намерение купить/надеть скин — шлём в runtime (он списывает рублики). */
requestBuySkin(slug, price) {
const rt = this.gameRuntime;
if (!rt) return;
try { rt._handleCommand?.(null, 'player.buySkin', { slug, price: price || 0 }); } catch (e) {}
}
/** Данные из загруженных проектом кастомных .glb-скинов (data-URL по slug). */
getAssetDataUrl(slug) {
try {
// Кастомные скины хранят dataUrl прямо в _skinsConfig.customGlbs.
const list = this._skinsConfig?.customGlbs || [];
const rec = list.find(g => g && g.slug === slug);
if (rec && rec.dataUrl) return rec.dataUrl;
} catch (e) {}
return null;
}
_onPlayerSkinChanged(slug) {
const s = this._ensureSkinShopState();
if (s.data) { s.data.current = slug; s.rev++; }
}
// ===== Библиотека пользовательских картинок (этап 3.6) =====
/** Список картинок проекта [{id, name, dataUrl}]. */
getAssets() {
return this.assetManager ? this.assetManager.list() : [];
}
/** Загрузить картинку из File. Возвращает Promise<{ok, id?, error?}>. */
addAssetFromFile(file) {
if (!this.assetManager) return Promise.resolve({ ok: false, error: 'нет менеджера' });
return this.assetManager.addFromFile(file).then((res) => {
if (res.ok && this._onSceneChange) this._onSceneChange();
return res;
});
}
renameAsset(id, name) {
if (!this.assetManager) return;
this.assetManager.rename(id, name);
if (this._onSceneChange) this._onSceneChange();
}
/**
* Удалить картинку. Снимает её с примитивов/GUI, которые на неё ссылались —
* иначе остались бы «висячие» ссылки на несуществующий ассет.
*/
removeAsset(id) {
if (!this.assetManager) return;
this.assetManager.remove(id);
// Снять текстуру с примитивов, использовавших этот ассет.
if (this.primitiveManager) {
for (const data of this.primitiveManager.instances.values()) {
if (data.textureAsset === id) {
this.primitiveManager.updateInstance(data.id, { textureAsset: null });
}
}
}
// Снять картинку с GUI-элементов image.
if (this.guiManager) {
for (const el of this.guiManager.getAll()) {
if (el.imageAsset === id) {
this.guiManager.update(el.id, { imageAsset: null });
}
}
}
if (this._onSceneChange) this._onSceneChange();
}
// ===== Библиотека пользовательских звуков (Фаза 5.5) =====
/** Список звуков проекта [{id, name, dataUrl}]. */
getSounds() {
return this.soundLibrary ? this.soundLibrary.list() : [];
}
/** Загрузить звук из File. Возвращает Promise<{ok, id?, error?}>. */
addSoundFromFile(file) {
if (!this.soundLibrary) return Promise.resolve({ ok: false, error: 'нет библиотеки' });
return this.soundLibrary.addFromFile(file).then((res) => {
if (res.ok && this._onSceneChange) this._onSceneChange();
return res;
});
}
renameSound(id, name) {
if (!this.soundLibrary) return;
this.soundLibrary.rename(id, name);
if (this._onSceneChange) this._onSceneChange();
}
removeSound(id) {
if (!this.soundLibrary) return;
this.soundLibrary.remove(id);
if (this._onSceneChange) this._onSceneChange();
}
// ===== Библиотека импортированных .glb-моделей (Фаза 5.8) =====
/** Список импортированных моделей [{id, name, dataUrl}]. */
getGlbModels() {
return this.glbLibrary ? this.glbLibrary.list() : [];
}
/** Загрузить .glb из File. Возвращает Promise<{ok, id?, error?}>. */
addGlbFromFile(file) {
if (!this.glbLibrary) return Promise.resolve({ ok: false, error: 'нет библиотеки' });
return this.glbLibrary.addFromFile(file).then((res) => {
if (res.ok && this._onSceneChange) this._onSceneChange();
return res;
});
}
renameGlb(id, name) {
if (!this.glbLibrary) return;
this.glbLibrary.rename(id, name);
if (this._onSceneChange) this._onSceneChange();
}
removeGlb(id) {
if (!this.glbLibrary) return;
this.glbLibrary.remove(id);
if (this._onSceneChange) this._onSceneChange();
}
/**
* Колбэк после постановки нового объекта (блока/модели/примитива).
* Используется KubikonEditor чтобы переключить activeTool на 'select'
* и дать пользователю сразу таскать поставленный объект.
*/
setOnPostPlace(cb) {
this._onPostPlace = cb;
}
/**
* Сохранить позиции всех unanchored объектов перед стартом физики.
* При exitPlayMode они возвращаются на эти позиции.
*/
_snapshotDynamicObjects() {
this._dynamicSnapshot = [];
if (this.blockManager) {
// Запоминаем позиции unanchored блоков (mesh-position).
// Сами блоки ОСТАЮТСЯ в blockManager.blocks Map, иначе вся остальная
// логика (сериализация, удаление, выделение) сломается.
// PhysicsAABB при Play фильтрует hasBlock через metadata.anchored
// и не считает unanchored клетку статичным препятствием.
for (const mesh of this.blockManager.blocks.values()) {
if (mesh.metadata?.anchored === false) {
this._dynamicSnapshot.push({
kind: 'block',
mesh,
x: mesh.position.x, y: mesh.position.y, z: mesh.position.z,
rotX: mesh.rotation?.x || 0,
rotY: mesh.rotation?.y || 0,
rotZ: mesh.rotation?.z || 0,
});
}
}
}
if (this.primitiveManager) {
for (const data of this.primitiveManager.instances.values()) {
if (data.anchored === false) {
this._dynamicSnapshot.push({
kind: 'primitive', data,
x: data.x, y: data.y, z: data.z,
rotX: data.mesh?.rotation?.x || 0,
rotY: data.mesh?.rotation?.y || 0,
rotZ: data.mesh?.rotation?.z || 0,
});
}
}
}
if (this.modelManager) {
for (const data of this.modelManager.instances.values()) {
if (data.anchored === false) {
this._dynamicSnapshot.push({
kind: 'model', data,
x: data.x, y: data.y, z: data.z,
rotX: data.rootMesh?.rotation?.x || 0,
rotY: data.rootMesh?.rotation?.y || 0,
rotZ: data.rootMesh?.rotation?.z || 0,
});
}
}
}
}
_restoreDynamicObjects() {
if (!this._dynamicSnapshot) return;
for (const snap of this._dynamicSnapshot) {
if (snap.kind === 'block' && snap.mesh) {
snap.mesh.position.x = snap.x;
snap.mesh.position.y = snap.y;
snap.mesh.position.z = snap.z;
if (snap.mesh.rotation) {
snap.mesh.rotation.x = snap.rotX || 0;
snap.mesh.rotation.y = snap.rotY || 0;
snap.mesh.rotation.z = snap.rotZ || 0;
}
snap.mesh.setEnabled(true);
} else if (snap.kind === 'primitive' && snap.data) {
snap.data.x = snap.x; snap.data.y = snap.y; snap.data.z = snap.z;
if (snap.data.mesh) {
snap.data.mesh.position.set(snap.x, snap.y, snap.z);
if (snap.data.mesh.rotation) {
snap.data.mesh.rotation.x = snap.rotX || 0;
snap.data.mesh.rotation.y = snap.rotY || 0;
snap.data.mesh.rotation.z = snap.rotZ || 0;
}
snap.data.mesh.setEnabled(snap.data.visible !== false);
}
} else if (snap.kind === 'model' && snap.data) {
snap.data.x = snap.x; snap.data.y = snap.y; snap.data.z = snap.z;
if (snap.data.rootMesh) {
snap.data.rootMesh.position.set(snap.x, snap.y, snap.z);
if (snap.data.rootMesh.rotation) {
snap.data.rootMesh.rotation.x = snap.rotX || 0;
snap.data.rootMesh.rotation.y = snap.rotY || 0;
snap.data.rootMesh.rotation.z = snap.rotZ || 0;
}
snap.data.rootMesh.setEnabled(true);
}
}
}
this._dynamicSnapshot = null;
}
/**
* Полный снимок сцены перед Play — примитивы и модели целиком.
* При exitPlayMode сцена восстанавливается ровно к этому состоянию:
* вернутся удалённые скриптом объекты, откатятся цвет/видимость/
* коллизия/поворот, исчезнут заспавненные скриптом объекты.
*
* Зачем: скрипты игр меняют сцену деструктивно (game.self.delete,
* setColor, tween rotationY и т.д.). Без полного отката после
* Stop→Play сцена остаётся «использованной» — собранные монетки
* не появляются, открытая дверь остаётся открытой. Это как Stop
* в Roblox Studio: сцена возвращается к авторскому виду.
*
* Блоки СЮДА НЕ входят — их скрипты практически не меняют, а полная
* пересборка тысяч блоков дорогая. Падающие unanchored-блоки и так
* откатываются через _restoreDynamicObjects (позиции).
*/
_snapshotFullScene() {
this._fullSceneSnapshot = null;
try {
const snap = {};
if (this.primitiveManager) {
snap.primitives = this.primitiveManager.serialize();
}
if (this.modelManager) {
snap.models = this.modelManager.serialize();
}
this._fullSceneSnapshot = snap;
} catch (e) {
console.warn('[BabylonScene] _snapshotFullScene не удался:', e);
this._fullSceneSnapshot = null;
}
}
/**
* Восстановить сцену из полного снимка после Play.
* Пересоздаёт примитивы и модели точь-в-точь (id сохраняются —
* addInstance принимает opts.id, поэтому скрипты на объектах после
* рестарта снова найдут свои target.id).
*/
_restoreFullScene() {
if (!this._fullSceneSnapshot) return;
const snap = this._fullSceneSnapshot;
this._fullSceneSnapshot = null;
try {
// Сбрасываем выделение — loadFromArray диспозит старые mesh,
// selection не должен держать мёртвую ссылку.
try { this.selection?.clear?.(); } catch (e) {}
if (this.primitiveManager && Array.isArray(snap.primitives)) {
this.primitiveManager.loadFromArray(snap.primitives);
}
if (this.modelManager && Array.isArray(snap.models)) {
// loadFromArray у моделей async (модели грузятся с диска) —
// не ждём, восстановление догрузится в фоне.
Promise.resolve(this.modelManager.loadFromArray(snap.models))
.catch((e) => console.warn('[BabylonScene] откат моделей:', e));
}
} catch (e) {
console.warn('[BabylonScene] _restoreFullScene не удался:', e);
}
}
/** Поменять anchored у выделенного объекта. */
setSelectedAnchored(anchored) {
this.selection?.setSelectedAnchored(anchored);
}
/** === Окружение / Время суток / Аудио / Вода === */
setEnvironmentPreset(preset) { this.environment?.setPreset(preset); }
setTimeOfDay(hour) { this.environment?.setTimeOfDay(hour); }
setCycleDuration(dayMin, nightMin) { this.environment?.setCycleDuration(dayMin, nightMin); }
setFog(enabled, color, density) { this.environment?.setFog(enabled, color, density); }
getEnvironmentState() { return this.environment?.serialize() || null; }
setAmbientAudio(opts) { this.audioManager?.setAmbient(opts); }
setMusicAudio(opts) { this.audioManager?.setMusic(opts); }
getAudioState() { return this.audioManager?.serialize() || null; }
/** Доступные пресеты амбиента/музыки для UI. */
getAudioPresets() {
return { ambient: AMBIENT_PRESETS || [], music: MUSIC_PRESETS || [] };
}
/** Доступные модели игрока (категория «Персонажи»). */
getPlayerOptions() {
// Импорт MODEL_TYPES сложен из engine, поэтому берём через _playerOptionsCache
return this._playerOptionsCache || [];
}
setPlayerOptions(list) { this._playerOptionsCache = list; }
/** Обновить пресет амбиента/музыки и обновить selection если открыт. */
setSoundProps(patch) {
if (!patch) return;
if (patch.ambientId !== undefined) {
this.audioManager?.setAmbient({ preset: patch.ambientId });
}
if (patch.musicId !== undefined) {
this.audioManager?.setMusic({ preset: patch.musicId });
}
if (this.selection?._selection?.type === 'sound') {
this.selection.selectSound();
}
}
/** Обновить тип персонажа / силу прыжка / прицел. */
setPlayerProps(patch) {
if (!patch) return;
if (patch.playerModelType) {
this.setPlayerModelType(patch.playerModelType);
}
if (typeof patch.jumpPower === 'number' && patch.jumpPower > 0) {
this.setPlayerJumpPower(patch.jumpPower);
}
if (typeof patch.crosshair === 'string') {
this.setCrosshair(patch.crosshair);
}
if (this.selection?._selection?.type === 'player') {
this.selection.selectPlayer();
}
}
/** Поменять mass у выделенного объекта. */
setSelectedMass(mass) {
this.selection?.setSelectedMass(mass);
}
/** Поменять свойства модели (canCollide / visible). */
setSelectedModelProps(patch) {
if (!this.selection) return;
const sel = this.selection.getSelection();
if (sel?.type === 'userModel') {
this.selection.setSelectedUserModelProps(patch);
return;
}
this.selection.setSelectedModelProps(patch);
}
/** Поменять свойства блока (canCollide / visible). */
setSelectedBlockProps(patch) {
this.selection?.setSelectedBlockProps(patch);
}
/** === Папки/группы === */
createFolder(name = 'Новая папка', parentId = null) {
return this.folderManager?.createFolder(name, parentId) ?? null;
}
renameFolder(id, name) { this.folderManager?.renameFolder(id, name); }
/** Переименовать скрипт по id. Имя сохраняется в поле name. */
renameScript(id, name) {
const i = this._scripts.findIndex(s => s.id === id);
if (i < 0) return false;
this._scripts[i] = { ...this._scripts[i], name: String(name || '').trim() || null };
this.history?.markChange();
if (this._onSceneChange) this._onSceneChange();
return true;
}
/** Переименовать инстанс модели. */
renameModel(instanceId, name) {
const data = this.modelManager?.instances?.get(instanceId);
if (!data) return false;
data.name = String(name || '').trim() || null;
if (this._onSceneChange) this._onSceneChange();
this.modelManager?._notifyChange?.();
return true;
}
/** Переименовать примитив. */
renamePrimitive(id, name) {
const data = this.primitiveManager?.instances?.get(id);
if (!data) return false;
data.name = String(name || '').trim() || null;
if (this._onSceneChange) this._onSceneChange();
this.primitiveManager?._notifyChange?.();
return true;
}
removeFolder(id, deleteContent = false) { this.folderManager?.removeFolder(id, deleteContent); }
setFolderVisible(id, visible) { this.folderManager?.setVisible(id, visible); }
assignToFolder(kind, ref, folderId) { this.folderManager?.assignToFolder(kind, ref, folderId); }
/** Положить выделенное в указанную папку (или null = в корень). */
assignSelectionToFolder(folderId) {
const sel = this.selection?.getSelection();
if (!sel) return;
if (sel.type === 'block') {
this.folderManager?.assignToFolder('block', { x: sel.gridX, y: sel.gridY, z: sel.gridZ }, folderId);
} else if (sel.type === 'model') {
this.folderManager?.assignToFolder('model', sel.instanceId, folderId);
} else if (sel.type === 'primitive') {
this.folderManager?.assignToFolder('primitive', sel.id, folderId);
}
}
/** Undo. */
undo() {
return this.history?.undo();
}
/** Redo. */
redo() {
return this.history?.redo();
}
/** Можно ли откатиться. */
canUndo() {
return !!this.history?.canUndo();
}
/** Можно ли вернуть. */
canRedo() {
return !!this.history?.canRedo();
}
/**
* Захватить превью-скриншот сцены как data URL (PNG, base64).
* Используется для иконки проекта в «Мои игры».
* size — размер квадратного превью в пикселях (по умолчанию 256).
*/
captureThumbnail(size = 256) {
if (!this.canvas) return null;
try {
// Простейший способ — взять текущий canvas-buffer и масштабировать его
// в новый offscreen canvas размера size×size.
const out = document.createElement('canvas');
out.width = size;
out.height = size;
const ctx = out.getContext('2d');
// Чёрная заливка на случай прозрачности
ctx.fillStyle = '#1a1410';
ctx.fillRect(0, 0, size, size);
// Принудительный рендер чтобы backbuffer был свежим
if (this.scene) this.scene.render();
// Сохраняем сохраняя пропорции — рисуем по короткой стороне
const sw = this.canvas.width, sh = this.canvas.height;
const minSide = Math.min(sw, sh);
const sx = (sw - minSide) / 2;
const sy = (sh - minSide) / 2;
ctx.drawImage(this.canvas, sx, sy, minSide, minSide, 0, 0, size, size);
return out.toDataURL('image/jpeg', 0.7); // JPEG-70% — ~10-30 КБ
} catch (e) {
// eslint-disable-next-line no-console
console.error('[BabylonScene] thumbnail error:', e);
return null;
}
}
// === СОХРАНЕНИЕ И ЗАГРУЗКА ===========================================
/**
* Сериализовать сцену в JSON-объект для сохранения в БД.
* Включает: блоки, модели, точку спавна, позицию редактор-камеры.
*/
/**
* Подготовить мини-карту для уже загруженного проекта (когда нет
* GeneratorParams). Считаем bbox реальных voxel'ов и сохраняем в
* window.__lastGenSize, чтобы MinimapOverlay масштабировался правильно.
* MinimapOverlay должна сама уметь рендерить real-data fallback.
*/
_setupMinimapForLoadedProject() {
if (!this.terrainManager || !this.terrainManager.voxels) return;
const voxels = this.terrainManager.voxels;
if (voxels.size === 0) return;
// Считаем bbox по X/Z. Берём max(|x|, |z|) как halfSize.
let maxAbs = 0;
for (const key of voxels.keys()) {
const lastComma = key.lastIndexOf(',');
const midComma = key.lastIndexOf(',', lastComma - 1);
const x = parseInt(key.slice(0, midComma), 10);
const z = parseInt(key.slice(lastComma + 1), 10);
const ax = Math.abs(x);
const az = Math.abs(z);
if (ax > maxAbs) maxAbs = ax;
if (az > maxAbs) maxAbs = az;
}
// maxAbs — в voxel-units. size для мини-карты = halfSize × 1.1 (запас).
const size = Math.ceil(maxAbs * 1.1);
window.__lastGenSize = size;
// Если ничего не было сгенерировано — НЕ ставим __lastGenParams.
// MinimapOverlay использует fallback на real-data top-down рендер.
console.log(`[BabylonScene] minimap configured for loaded project: half-size=${size} voxel-units (${(size * 2 * 0.25).toFixed(0)}м)`);
}
/**
* Подготовить мини-карту для гладкого ландшафта (RobloxTerrain).
*
* Воксельная миникарта (_setupMinimapForLoadedProject) читает
* terrainManager.voxels — для smooth terrain их нет. Здесь публикуем
* ссылку на density-grid в window.__robloxMinimapGrid, а MinimapOverlay
* сам строит top-down heightmap из неё.
*
* Ставим _terrainStreamingEnabled=true чтобы MinimapOverlay стал visible
* (он показывается по этому флагу), даже если у гладкого ландшафта
* нет настоящего streaming-режима.
*/
_setupMinimapForRobloxTerrain() {
const grid = this._robloxTerrain?.grid;
if (!grid) return;
// CELL_SIZE=4м. Полная ширина карты в метрах.
const worldM = grid.size.x * 4;
// MinimapOverlay масштаб: getWorldViewM() = size*2*0.25.
// Чтобы worldViewM == worldM → size = worldM*2.
window.__lastGenSize = worldM * 2;
window.__lastGenParams = null; // не procedural-режим
window.__robloxMinimapGrid = grid; // density-grid для real-data рендера
this._terrainStreamingEnabled = true; // делает MinimapOverlay видимым
// ВАЖНО: _worldHalf по умолчанию 40 — на больших гладких картах это
// зажимало зомби/мобов в крошечный квадрат ±40 (они телепортировались
// к центру и проваливались). Подгоняем под реальный размер карты.
const half = Math.ceil(worldM / 2);
if (this._worldHalf < half) {
this._worldHalf = half;
console.log(`[BabylonScene] _worldHalf -> ${half} (под размер гладкого ландшафта)`);
}
console.log(`[BabylonScene] minimap configured for RobloxTerrain: ${worldM}м (grid ${grid.size.x}×${grid.size.y}×${grid.size.z})`);
}
/**
* Снять ТОЧНУЮ карту высот гладкого ландшафта (RobloxTerrain).
*
* Зачем: density-grid квантует высоту по 4м, а Surface Nets рендерит
* сглаженную поверхность между ячейками — реальная видимая высота
* отличается от грубой оценки по grid. Чтобы корректно ставить
* объекты/блоки на землю, нужна высота РЕАЛЬНОГО меша.
*
* Метод raycast'ит сверху-вниз по мешам RobloxTerrain в сетке точек
* (шаг step метров) и возвращает объект с картой высот. Используется
* билд-скриптами игр для точного размещения.
*
* @param {number} step шаг сетки в метрах (по умолчанию 2)
* @returns {object|null} { format, origin, worldSize, step, cols, rows, heights[] }
* heights — плоский массив (rows × cols), значение = Y поверхности или null.
*/
exportRobloxHeightmap(step = 2) {
const rt = this._robloxTerrain;
if (!rt || !rt.grid) {
console.warn('[exportRobloxHeightmap] нет гладкого ландшафта');
return null;
}
const grid = rt.grid;
const CS = 4; // CELL_SIZE
// Мировые границы карты по grid.
const minWX = grid.origin.x * CS;
const minWZ = grid.origin.z * CS;
const worldX = grid.size.x * CS;
const worldZ = grid.size.z * CS;
const cols = Math.ceil(worldX / step) + 1;
const rows = Math.ceil(worldZ / step) + 1;
// Предикат: только меши гладкого ландшафта.
const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain);
const heights = new Array(cols * rows).fill(null);
let hit = 0;
const t0 = performance.now();
for (let r = 0; r < rows; r++) {
const wz = minWZ + r * step;
for (let c = 0; c < cols; c++) {
const wx = minWX + c * step;
const ray = new Ray(new Vector3(wx, 5000, wz), new Vector3(0, -1, 0), 10000);
const pick = this.scene.pickWithRay(ray, pickPred);
if (pick && pick.hit && pick.pickedPoint) {
heights[r * cols + c] = +pick.pickedPoint.y.toFixed(3);
hit++;
}
}
}
const dt = (performance.now() - t0).toFixed(0);
console.log(`[exportRobloxHeightmap] ${cols}×${rows} точек, ${hit} попаданий, ${dt}мс`);
return {
format: 'roblox-heightmap-v1',
origin: { x: minWX, z: minWZ },
worldSize: { x: worldX, z: worldZ },
gridOrigin: { ...grid.origin },
gridSize: { ...grid.size },
step,
cols,
rows,
heights,
};
}
serialize() {
// Принадлежность объектов папкам — серилизуется в их собственных
// данных (folderId), а сами папки в отдельном массиве.
const blocksWithFolders = this.blockManager ? this.blockManager.serialize() : [];
// BlockManager.serialize не знает про folderId — добавляем его поверх.
if (this.blockManager) {
for (const item of blocksWithFolders) {
const mesh = this.blockManager.blocks.get(`${item.x},${item.y},${item.z}`);
item.folderId = mesh?.metadata?.folderId ?? null;
}
}
const modelsWithFolders = this.modelManager ? this.modelManager.serialize() : [];
if (this.modelManager) {
// Дописываем instanceId + folderId поверх стандартной сериализации
// (которая уже включает type/x/y/z/rotationY/anchored/canCollide/visible/mass)
const live = Array.from(this.modelManager.instances.values());
for (let i = 0; i < modelsWithFolders.length && i < live.length; i++) {
modelsWithFolders[i].instanceId = live[i].instanceId;
modelsWithFolders[i].folderId = live[i].folderId ?? null;
}
}
const primitivesWithFolders = this.primitiveManager ? this.primitiveManager.getAll() : [];
if (this.primitiveManager) {
for (let i = 0; i < primitivesWithFolders.length; i++) {
const id = primitivesWithFolders[i].id;
const data = this.primitiveManager.instances.get(id);
primitivesWithFolders[i].folderId = data?.folderId ?? null;
}
}
// Terrain: RLE-формат для больших карт (×25 меньше чем legacy array).
// На карте 250м: ~1.5МБ вместо ~38МБ.
let terrainData;
if (this.terrainManager) {
const voxelCount = this.terrainManager.voxels?.size ?? 0;
if (voxelCount > 5000 && typeof this.terrainManager.serializeRLE === 'function') {
terrainData = this.terrainManager.serializeRLE();
} else {
terrainData = this.terrainManager.serialize();
}
} else {
terrainData = [];
}
// Roblox smooth terrain — отдельная подсистема, сериализуется параллельно
let robloxTerrainData = null;
if (this._robloxTerrain && this._robloxTerrain.grid) {
try {
robloxTerrainData = this._robloxTerrain.serialize();
// Декорации сохраняем двумя способами:
// - decoParams (seed + density) — для процедурной генерации
// - decoInstances (полные матрицы) — для ручных правок plant-кистью
// При load — приоритет у decoInstances если они есть.
if (this._smoothDecoParams) {
robloxTerrainData.decoParams = this._smoothDecoParams;
}
if (this._smoothDecoManager) {
const stats = this._smoothDecoManager.getStats?.();
if (stats && stats.total > 0) {
try {
robloxTerrainData.decoInstances = this._smoothDecoManager.serialize();
} catch (e) {
console.warn('smoothDeco serialize failed:', e);
}
}
}
} catch (e) { console.warn('robloxTerrain serialize failed:', e); }
}
return {
version: 1,
scene: {
blocks: blocksWithFolders,
models: modelsWithFolders,
primitives: primitivesWithFolders,
// Этап 5: пользовательские воксельные модели (созданные через
// ModelEditorScreen). Каждая запись: { type:'user:42', x,y,z, ry }.
userModels: this.userModelManager ? this.userModelManager.serialize() : [],
terrain: terrainData,
robloxTerrain: robloxTerrainData,
decorations: this.decoManager ? this.decoManager.serialize() : [],
folders: this.folderManager ? this.folderManager.serialize() : [],
gui: this.guiManager ? this.guiManager.serialize() : [],
inventory: this.inventory ? this.inventory.serialize() : null,
spawnPoint: { ...this._spawnPoint },
playerModelType: this._playerModelType,
skins: this._skinsConfig ? {
default: this._skinsConfig.default || null,
unlocked: this._skinsConfig.unlocked || [],
shopVisible: this._skinsConfig.shopVisible !== false,
coins: this._skinsConfig.coins || 0,
customGlbs: this._skinsConfig.customGlbs || [],
} : undefined,
worldSize: this._worldHalf * 2,
floorEnabled: this._floorEnabled !== false,
jumpPowerMul: this._jumpPowerMul ?? 1,
cameraMode: this._defaultCameraMode || 'third',
crosshair: this._crosshair || 'dot',
shadowQuality: this._shadowQuality || 'soft',
environment: this.environment ? this.environment.serialize() : null,
audio: this.audioManager ? this.audioManager.serialize() : null,
// Библиотека пользовательских картинок (текстуры/GUI-image).
assets: this.assetManager ? this.assetManager.serialize() : [],
// Библиотека пользовательских звуков (Фаза 5.5).
sounds: this.soundLibrary ? this.soundLibrary.serialize() : [],
// Импортированные .glb-модели (Фаза 5.8).
glbModels: this.glbLibrary ? this.glbLibrary.serialize() : [],
// ЭТАП 2.1: пропускаем demo-скрипт (он добавляется автоматически
// при загрузке если у проекта нет своих скриптов).
scripts: this._scripts
.filter(s => s.id !== 'demo')
.map(s => ({
id: s.id,
code: s.code,
target: s.target || null,
name: s.name || null,
})),
},
editorCamera: this.camera ? {
position: { x: this.camera.position.x, y: this.camera.position.y, z: this.camera.position.z },
rotation: { x: this.camera.rotation.x, y: this.camera.rotation.y, z: this.camera.rotation.z },
} : null,
settings: {
// GD-проект (Geometry Dash) — включает GD-визуал в Play.
// Раньше определялось по диапазону id (296-395), но диапазон
// зарезервирован с запасом — обычные проекты туда попадали.
// Теперь — явный флаг в данных проекта.
isGd: this._isGdProject === true,
},
};
}
/**
* Восстановить сцену из ранее сохранённого state.
* Очищает существующие блоки/модели, создаёт заново.
* Возвращает promise (async из-за загрузки моделей).
*/
async loadFromState(state) {
if (!state || !state.scene) return;
// Флаг GD-проекта — из settings. Если в данных проекта флага нет
// (старые проекты до введения флага) — _isGdProject останется
// undefined, и enterPlayMode сделает fallback на диапазон id.
if (state.settings && typeof state.settings.isGd === 'boolean') {
this._isGdProject = state.settings.isGd;
}
// Библиотека пользовательских картинок — грузим РАНЬШЕ примитивов,
// чтобы при создании примитива с textureAsset текстура уже была доступна.
if (this.assetManager) {
this.assetManager.load(Array.isArray(state.scene.assets) ? state.scene.assets : []);
}
// Библиотека пользовательских звуков (Фаза 5.5).
if (this.soundLibrary) {
this.soundLibrary.load(Array.isArray(state.scene.sounds) ? state.scene.sounds : []);
}
// Импортированные .glb-модели (Фаза 5.8) — грузим РАНЬШЕ моделей,
// чтобы при addInstance('glb:N') библиотека была готова.
if (this.glbLibrary) {
this.glbLibrary.load(Array.isArray(state.scene.glbModels) ? state.scene.glbModels : []);
}
// Размер пола — пересоздаём пол если у проекта он другой
if (typeof state.scene.worldSize === 'number' && state.scene.worldSize > 0) {
this.setWorldSize(state.scene.worldSize);
}
if (typeof state.scene.floorEnabled === 'boolean') {
this.setFloorEnabled(state.scene.floorEnabled);
}
if (typeof state.scene.jumpPowerMul === 'number' && state.scene.jumpPowerMul > 0) {
this.setPlayerJumpPower(state.scene.jumpPowerMul);
}
if (typeof state.scene.crosshair === 'string') {
this.setCrosshair(state.scene.crosshair);
}
// Камера по умолчанию ('first' / 'third' / 'front'). Применяется при enterPlayMode.
if (typeof state.scene.cameraMode === 'string') {
this._defaultCameraMode = state.scene.cameraMode;
}
// Качество теней
if (state.scene.shadowQuality) {
this.setShadowQuality(state.scene.shadowQuality);
}
// Блоки — синхронно
if (this.blockManager && Array.isArray(state.scene.blocks)) {
this.blockManager.loadFromArray(state.scene.blocks);
}
// Террейн (voxel-ландшафт). Поддерживаем 2 формата:
// 1. Legacy: terrain = [{x,y,z,m}, ...] — старые проекты
// 2. RLE-v1: terrain = {format:'rle-v1', palette, chunks:{base64}}
// — новый формат для больших карт (×25 меньше)
const ts = state.scene.terrain;
// Прогресс-индикатор: глобальный объект, KubikonEditor.jsx читает в polling
const setProgress = (percent, label) => {
if (typeof window !== 'undefined') {
window.__kubikonLoadProgress = { percent, label, ts: performance.now() };
}
};
setProgress(2, 'Подготовка сцены…');
if (this.terrainManager && ts) {
const tLoad0 = performance.now();
if (Array.isArray(ts)) {
// Legacy формат
const tCount = ts.length;
console.log(`[BabylonScene] LOAD terrain (legacy): ${tCount} voxels`);
await this.terrainManager.loadFromArray(ts, (loaded, total) => {
if (total > 5000 && loaded % 10000 < 2001) {
console.log(`[BabylonScene] terrain: ${loaded}/${total}`);
// 5-40% — заливка вокселей
setProgress(5 + Math.floor((loaded / total) * 35), `Размещение блоков: ${loaded.toLocaleString()} / ${total.toLocaleString()}`);
}
});
} else if (ts.format === 'rle-v1' && typeof this.terrainManager.loadFromRLE === 'function') {
// RLE формат
const chunkCount = Object.keys(ts.chunks || {}).length;
console.log(`[BabylonScene] LOAD terrain (RLE): ${chunkCount} chunks, palette=${(ts.palette || []).length - 1}`);
await this.terrainManager.loadFromRLE(ts, (loaded, total) => {
if (loaded % 16 === 0) {
console.log(`[BabylonScene] terrain RLE: chunk ${loaded}/${total}`);
// 5-40% — распаковка RLE
setProgress(5 + Math.floor((loaded / total) * 35), `Распаковка карты: ${loaded} / ${total} чанков`);
}
});
} else {
console.warn('[BabylonScene] unknown terrain format:', ts);
}
setProgress(75, 'Сборка геометрии регионов…');
const tLoad1 = performance.now();
const finalVoxelCount = this.terrainManager.voxels?.size ?? 0;
const regionCount = this.terrainManager.getRegionCount?.() ?? 0;
console.log(`[BabylonScene] LOAD done in ${(tLoad1 - tLoad0).toFixed(0)}ms: ${finalVoxelCount} voxels → ${regionCount} regions`);
// Shadow-load в VoxelWorld — ТОЛЬКО для маленьких карт.
// На больших (>30K voxels) это лишняя нагрузка (память + время).
// Если когда-то перейдём на новый рендер — сами решим shadow-load заново.
if (Array.isArray(ts) && finalVoxelCount > 0 && finalVoxelCount < 30000) {
try {
this.voxelWorld.loadLegacyTerrain(ts);
const s = this.voxelWorld.stats();
console.log(`[VoxelWorld] shadow-loaded: ${s.totalVoxels} voxels in ${s.totalChunks} chunks`);
} catch (e) {
console.warn('[VoxelWorld] shadow-load failed:', e);
}
} else if (finalVoxelCount >= 30000) {
console.log(`[VoxelWorld] shadow-load SKIPPED (${finalVoxelCount} voxels > 30000 — экономим память)`);
}
// === АВТО-STREAMING для загруженных больших проектов ===
if (regionCount > 0) {
this._terrainStreamingEnabled = true;
// Адаптивный radius (2026-05-27 увеличен): на средних картах
// 1-3M voxels старый radius 28-32м был слишком мал для
// замкнутых объектов (вулкан) — дальние регионы стенки не
// материализуются, игрок видит «полупрозрачные» стены.
let radius = 80;
if (finalVoxelCount > 5_000_000) radius = 40;
else if (finalVoxelCount > 3_000_000) radius = 55;
else if (finalVoxelCount > 1_000_000) radius = 70;
this._terrainStreamingRadius = radius;
this._terrainStreamingLastUpdate = 0;
// Автотуман для скрытия границы streaming. Без него видно
// резкий обрыв террейна на радиусе.
//
// Density подбираем по editor-radius: чем больше radius,
// тем дальше начало тумана. Для radius=72м: 0.005 — туман
// едва заметен в ближнем плане, но прячет обрыв на 70м.
try {
if (this.scene) {
const camY = this.camera?.position.y || 0;
const heightBonus = Math.max(0, Math.min(20, camY * 0.3));
const effectiveRadius = Math.min(60, this._terrainStreamingRadius * 1.3 + heightBonus);
// Эмпирически: fogDensity ≈ 0.5/radius работает.
// На radius=72м → 0.007 (туман на 70-90м)
// На radius=40м → 0.0125 (туман на 40-60м, как раньше)
const density = Math.max(0.004, Math.min(0.014, 0.5 / effectiveRadius));
this.scene.fogMode = 2; // FOGMODE_EXP
this.scene.fogColor = new Color3(0.55, 0.7, 0.85); // светло-голубой
this.scene.fogDensity = density;
}
} catch (e) {}
// Сразу первый pass с editor-radius формулой (см. render loop).
if (this.camera) {
const camY = this.camera.position.y || 0;
const heightBonus = Math.max(0, Math.min(20, camY * 0.3));
const editorRadius = Math.min(60, this._terrainStreamingRadius * 1.3 + heightBonus);
const r = this.terrainManager.updateStreaming(
this.camera.position.x, this.camera.position.z,
editorRadius,
);
console.log(`[BabylonScene] auto-streaming ON: ${r.enabled}/${r.total} regions visible (editor radius=${editorRadius.toFixed(0)}m, play radius=${this._terrainStreamingRadius}m)`);
}
this._setupMinimapForLoadedProject();
} else {
console.log(`[BabylonScene] streaming NOT enabled (regionCount=0). Карта маленькая или region-split не сработал.`);
}
}
// === Загрузка Roblox smooth terrain (параллельная подсистема) ===
// Если в проекте есть robloxTerrain — создаём менеджер и загружаем grid.
const rts = state.scene.robloxTerrain;
if (rts && rts.format === 'robloxterrain-v1') {
try {
setProgress(90, 'Загрузка гладкого ландшафта…');
if (!this._robloxTerrain) {
this._robloxTerrain = new RobloxTerrain(this.scene);
if (this.physics?.setRobloxTerrain) {
this.physics.setRobloxTerrain(this._robloxTerrain);
}
}
this._robloxTerrain.loadFromState(rts);
// Сразу материализуем chunks вокруг камеры
const camX = this.camera?.position.x || 0;
const camZ = this.camera?.position.z || 0;
const r = this._robloxTerrain.updateStreaming(camX, camZ, 99999, { maxBuild: 9999 });
const stats = this._robloxTerrain.getStats();
console.log(`[BabylonScene] LOAD robloxTerrain: ${r.built} chunks, ${stats.triangles} triangles`);
// Включаем мини-карту для гладкого ландшафта — MinimapOverlay
// показывается по флагу _terrainStreamingEnabled, а heightmap
// строит из density-grid (window.__robloxMinimapGrid).
this._setupMinimapForRobloxTerrain();
// Если есть RobloxTerrain — отключаем baseplate-floor чтобы
// не создавал ложных коллизий под smooth-ландшафтом.
// НО только если рельеф большой (≥500 cells) — иначе baseplate
// нужен для визуального ориентирования и для plant-decos
// которые ставятся на y=0.
if (stats.solidCells >= 500) {
try { this.setFloorEnabled(false); } catch (e) {}
}
// === Загрузка smooth-decorations ===
// Приоритет 1: decoInstances (точные матрицы, ручные правки plant-кистью)
// Приоритет 2: decoParams (seed-based процедурная генерация)
if (rts.decoInstances && rts.decoInstances.items?.length > 0) {
try {
if (!this._smoothDecoManager) {
this._smoothDecoManager = new SmoothDecoManager(this.scene);
}
const r = await this._smoothDecoManager.loadFromState(rts.decoInstances);
const total = r?.total ?? 0;
const tc = r?.treeColliders ?? [];
console.log(`[BabylonScene] LOAD smooth decorations (instances): ${total} (${tc.length} tree colliders)`);
if (this.physics?.setSmoothDecoTrees) {
this.physics.setSmoothDecoTrees(tc);
}
} catch (err) {
console.error('[BabylonScene] smooth decoInstances load failed:', err);
}
} else if (rts.decoParams) {
try {
const dp = rts.decoParams;
this._smoothDecoParams = dp;
if (!this._smoothDecoManager) {
this._smoothDecoManager = new SmoothDecoManager(this.scene);
}
await this._smoothDecoManager.loadAll();
const decoGen = new WorldGenerator(dp.genParams);
const sampleSurfaceY = (x, z) => {
if (!this.physics?._sampleRobloxSurface) return null;
return this.physics._sampleRobloxSurface(x, z);
};
const sampleBiomeId = (x, z) => {
const b = decoGen.sampleBiome(x * 4, z * 4);
return b?.id;
};
const r2 = this._smoothDecoManager.placeDecorations({
sampleSurfaceY, sampleBiomeId, bbox: dp.bbox,
densityFlowers: dp.flowersDensity,
densityGrass: dp.grassDensity,
densityTrees: dp.treesDensity ?? 0,
seed: dp.seed,
});
console.log(`[BabylonScene] LOAD smooth decorations (params): ${r2.total} instances`);
if (this.physics?.setSmoothDecoTrees && r2.treeColliders) {
this.physics.setSmoothDecoTrees(r2.treeColliders);
}
} catch (err) {
console.error('[BabylonScene] smooth decorations load failed:', err);
}
}
} catch (err) {
console.error('[BabylonScene] robloxTerrain load failed:', err);
}
}
setProgress(95, 'Размещение декораций…');
// Этап 6: загрузка decorations (цветы/грибы/трава мини-вокселями).
if (this.decoManager && Array.isArray(state.scene.decorations)) {
this.decoManager.loadFromArray(state.scene.decorations);
// Этап D LOD: первый pass streaming для деко.
// maxBuild=2 — строим только 2 ближайших chunk сразу (карта появляется
// мгновенно). Остальные доступные подгружаются по 2-4 за тик
// (200мс), весь видимый набор за 1-2 секунды. UI не блокируется.
if (this.camera && this.decoManager.updateStreaming) {
const decoRadius = Math.max(18, (this._terrainStreamingRadius || 40) * 0.35);
const r = this.decoManager.updateStreaming(
this.camera.position.x, this.camera.position.z, decoRadius,
{ maxBuild: 2 },
);
console.log(`[BabylonScene] deco streaming ON: ${r.visible}/${r.total} chunks visible (radius=${decoRadius.toFixed(0)}m)`);
}
}
// Модели — асинхронно (GLB подгружаются)
if (this.modelManager && Array.isArray(state.scene.models)) {
await this.modelManager.loadFromArray(state.scene.models);
}
// Этап 5: пользовательские воксельные модели — асинхронно
// (model_data грузится через API). Каждый item: {type:'user:42', x,y,z,ry,scale,canCollide,...}.
if (this.userModelManager && Array.isArray(state.scene.userModels)
&& state.scene.userModels.length > 0) {
const loaded = await this.userModelManager.loadFromArray(
state.scene.userModels,
{ currentUserId: this._currentUserId },
);
console.log(`[BabylonScene] user models loaded: ${loaded}/${state.scene.userModels.length}`);
// Регистрируем коллайдеры в физике
this._syncUserModelColliders();
}
// ОПТИМИЗАЦИЯ: предзагружаем модель ИГРОКА (character) тоже —
// PlayerController.start() её ждёт, но если предзагрузить сейчас,
// на enterPlayMode она будет в кэше Babylon и стартует мгновенно.
// ВАЖНО: R15-скины ('skin_*') — отдельная система (characters/<id>/
// body.glb + манифест), ModelManager их не знает. Их грузит сам
// PlayerController через _loadSkinManifest — здесь пропускаем,
// иначе ModelManager пишет в консоль 'Unknown model type'.
try {
const playerModelType = this._playerModelType || 'character-a';
if (!String(playerModelType).startsWith('skin_')) {
await this.modelManager?._loadPrototype?.(playerModelType);
}
} catch (e) { /* ignore */ }
// Примитивы — синхронно
if (this.primitiveManager && Array.isArray(state.scene.primitives)) {
this.primitiveManager.loadFromArray(state.scene.primitives);
}
// Папки + восстановление folderId на всех объектах
if (this.folderManager) {
this.folderManager.loadFromArray(state.scene.folders || []);
}
// GUI-элементы
if (this.guiManager) {
this.guiManager.loadFromArray(state.scene.gui || []);
}
// Инвентарь
if (this.inventory) {
this.inventory.loadFromArray(state.scene.inventory || null);
}
// Простановка folderId на блоках (loadFromArray BlockManager не знает про это поле)
if (this.blockManager && Array.isArray(state.scene.blocks)) {
for (const b of state.scene.blocks) {
if (b.folderId == null) continue;
const mesh = this.blockManager.blocks.get(`${b.x},${b.y},${b.z}`);
if (mesh && mesh.metadata) mesh.metadata.folderId = b.folderId;
}
}
if (this.modelManager && Array.isArray(state.scene.models)) {
// ModelManager.loadFromArray генерирует новые instanceId,
// поэтому folderId восстанавливаем по индексу (порядку).
const arr = state.scene.models;
const liveIds = Array.from(this.modelManager.instances.keys());
for (let i = 0; i < arr.length && i < liveIds.length; i++) {
if (arr[i].folderId == null) continue;
const data = this.modelManager.instances.get(liveIds[i]);
if (data) data.folderId = arr[i].folderId;
}
}
if (this.primitiveManager && Array.isArray(state.scene.primitives)) {
// primitiveManager после loadFromArray генерирует новые id, поэтому
// восстановим folderId по индексу (порядку) — он совпадает.
const arr = state.scene.primitives;
const liveIds = Array.from(this.primitiveManager.instances.keys());
for (let i = 0; i < arr.length && i < liveIds.length; i++) {
if (arr[i].folderId == null) continue;
const data = this.primitiveManager.instances.get(liveIds[i]);
if (data) data.folderId = arr[i].folderId;
}
}
// После расстановки folderId — применим эффективную видимость папок
if (this.folderManager) {
for (const f of this.folderManager.getAll()) {
this.folderManager._applyVisibility(f.id, this.folderManager._effectiveVisible(f.id));
}
}
// Зарегистрировать все объекты как shadow casters
this.refreshAllShadows();
// Точка спавна
if (state.scene.spawnPoint) {
this._spawnPoint = { ...state.scene.spawnPoint };
this._updateSpawnMarker();
}
// === Авто-fix спавна для smooth terrain ===
// Если есть RobloxTerrain и spawnPoint оказался НИЖЕ поверхности —
// поднимаем его, чтобы игрок не провалился в момент нажатия "Запустить".
// Иначе raycast pickWithRay возвращает hit В ОБОИХ направлениях (mesh
// обволакивает AABB), физика застревает в UNSTUCK-цикле и игрок падает
// в бездну.
try {
if (this._robloxTerrain
&& (this._robloxTerrain.getStats?.().solidCells ?? 0) > 0
&& this.physics?._sampleRobloxSurface) {
const surfaceY = this.physics._sampleRobloxSurface(this._spawnPoint.x, this._spawnPoint.z);
if (surfaceY !== null && this._spawnPoint.y < surfaceY + 1) {
const newY = surfaceY + 2;
console.log(`[BabylonScene] spawn auto-lifted: y ${this._spawnPoint.y.toFixed(2)}${newY.toFixed(2)} (surface=${surfaceY.toFixed(2)})`);
this._spawnPoint.y = newY;
this._updateSpawnMarker();
} else if (surfaceY === null) {
console.warn('[BabylonScene] spawn auto-lift: no surface found under spawn');
}
}
} catch (e) { console.warn('[BabylonScene] spawn auto-lift failed:', e); }
// Тип модели персонажа.
// Миграция: старые проекты сохраняли Kenney-модель ('character-a..g').
// Теперь стандарт — R15-скин bacon-hair. Если в проекте старая
// Kenney-модель — форсим bacon-hair. Явно выбранные 'skin_*' не трогаем.
if (state.scene.playerModelType) {
const pmt = state.scene.playerModelType;
if (pmt.startsWith('character-')) {
this._playerModelType = 'skin_bacon-hair';
} else {
this._playerModelType = pmt;
}
}
// Задача 07: конфиг скинов проекта { default, unlocked, shopVisible, coins, customGlbs }.
if (state.scene.skins && typeof state.scene.skins === 'object') {
this._skinsConfig = {
default: state.scene.skins.default || null,
unlocked: Array.isArray(state.scene.skins.unlocked) ? state.scene.skins.unlocked.slice() : [],
shopVisible: state.scene.skins.shopVisible !== false,
coins: Number.isFinite(state.scene.skins.coins) ? state.scene.skins.coins : 0,
customGlbs: Array.isArray(state.scene.skins.customGlbs) ? state.scene.skins.customGlbs.slice() : [],
};
// Стартовый скин из skins.default имеет приоритет над playerModelType.
if (this._skinsConfig.default) {
const d = this._skinsConfig.default;
this._playerModelType = d.startsWith('character-') || d.startsWith('skin_') || d.startsWith('customskin:')
? d : ('skin_' + d);
}
} else {
this._skinsConfig = null;
}
// Пользовательские скрипты
if (Array.isArray(state.scene.scripts)) {
this._scripts = state.scene.scripts
.filter(s => s && typeof s.code === 'string')
.map(s => ({
id: s.id || `script_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
code: s.code,
target: s.target || null,
name: s.name || null,
}));
}
// Окружение (время суток, скайбокс, туман)
if (state.scene.environment && this.environment) {
this.environment.load(state.scene.environment);
}
// Аудио (фоновая музыка/амбиент)
if (state.scene.audio && this.audioManager) {
this.audioManager.load(state.scene.audio);
}
// Редактор-камера
if (state.editorCamera && this.camera) {
const c = state.editorCamera;
if (c.position) this.camera.position = new Vector3(c.position.x, c.position.y, c.position.z);
if (c.rotation) this.camera.rotation = new Vector3(c.rotation.x, c.rotation.y, c.rotation.z);
}
// Финальный прогресс — UI скроет overlay
if (typeof window !== 'undefined') {
window.__kubikonLoadProgress = { percent: 100, label: 'Готово!', ts: performance.now() };
}
}
/**
* Задача 08: активировать pointer-примитивы из палитры в реальные стрелки.
* Маркер-сфера прячется, через BeamManager создаётся анимированная стрелка
* от источника (игрок/объект) к цели. from/to берутся из инспектора.
*/
_activatePointers() {
const pm = this.primitiveManager;
const bm = this.beamManager;
if (!pm || !bm) return;
let cnt = 0;
for (const inst of pm.instances.values()) {
if (inst.type !== 'pointer') continue;
cnt++;
try { if (inst.mesh) inst.mesh.setEnabled(false); } catch (e) {}
const at = { x: inst.x, y: inst.y, z: inst.z };
const from = this._pointerRefOrPoint(inst.pointerFrom, at);
const to = this._pointerRefOrPoint(inst.pointerTo, { x: at.x, y: at.y, z: at.z + 4 });
try {
const pid = bm.addPointer({
from, to,
preset: inst.pointerPreset || 'guide',
color: inst.color, textureSpeed: inst.textureSpeed,
curved: inst.curved, curveHeight: inst.curveHeight,
});
} catch (e) {
}
}
}
/** from/to стрелки: 'player' | id примитива/модели → ref | точка-fallback. */
_pointerRefOrPoint(val, fallbackPoint) {
if (val === 'player') return 'player';
if (val != null && val !== '') {
const n = Number(val);
if (Number.isFinite(n)) {
if (this.primitiveManager?.instances?.has(n)) return 'primitive:' + n;
if (this.modelManager?.instances?.has(n)) return 'model:' + n;
}
if (typeof val === 'string'
&& (val.startsWith('primitive:') || val.startsWith('model:'))) return val;
}
return fallbackPoint;
}
/** Выйти из режима игры — восстановить редактор-камеру. */
exitPlayMode() {
if (!this._isPlaying) return;
this._isPlaying = false;
// Задача 04: закрываем любой активный модал чтобы не «висел» при стопе
try { this.modalManager?._instantClose?.(); } catch (e) {}
// Сбрасываем таймер прохождения
this._timerRunning = false;
this._timerStartedAt = null;
// Отключаем picking voxel-террейна обратно (нужно только в play).
try { this.terrainManager?.enablePickingForCamera?.(false); } catch (e) {}
// Phase 6.4: чистим скрипт-созданные кастомные tool из инвентаря,
// чтобы они не накапливались между Play. Обычное оружие остаётся.
if (this.inventory) {
for (let i = 0; i < this.inventory.slots.length; i++) {
const s = this.inventory.slots[i];
if (s && s.params && s.params._customToolId) {
this.inventory.removeSlot(i);
}
}
}
// Размораживаем world-matrix статичных моделей — в редакторе
// пользователь может двигать их через гизмо.
try { this.modelManager?.unfreezeStaticModels?.(); } catch (e) {}
try { this.primitiveManager?.unfreezeStaticPrimitives?.(); } catch (e) {}
// Возвращаем все примитивы в видимое состояние (LOD-cull сбрасывается)
if (this.primitiveManager) {
for (const data of this.primitiveManager.instances.values()) {
const m = data.mesh;
if (m && m._kubikonPrimCulled === true) {
m.setEnabled(data.visible !== false);
m._kubikonPrimCulled = false;
}
}
}
// Останавливаем пользовательские скрипты ПЕРЕД уничтожением player'а,
// чтобы скрипты не успели потрогать player в момент disposal.
if (this.gameRuntime) {
this.gameRuntime.stop();
this.gameRuntime = null;
}
// Останавливаем GD-level manager (сбрасывает _shipMode/_ufoMode/... на player перед его удалением)
if (this.gdLevelManager) {
this.gdLevelManager.stop();
}
// Этап G1: убрать skybox/параллакс (откатывает fog/clearColor)
if (this.gdSkybox) {
try { this.gdSkybox.dispose(); } catch (e) {}
this.gdSkybox = null;
}
// Этап G2: убрать декоративную траву + neon-edge
if (this.gdGroundSkin) {
try { this.gdGroundSkin.dispose(); } catch (e) {}
this.gdGroundSkin = null;
}
// Этап G3: убрать кастомные шипы, вернуть оригинальные конусы
if (this.gdSpikes) {
try { this.gdSpikes.dispose(); } catch (e) {}
this.gdSpikes = null;
}
// Этап G4: убрать стартовую арку
if (this.gdStartArch) {
try { this.gdStartArch.dispose(); } catch (e) {}
this.gdStartArch = null;
}
// Этап G5: убрать финиш-ворота
if (this.gdFinish) {
try { this.gdFinish.dispose(); } catch (e) {}
this.gdFinish = null;
}
// Этап G6: убрать деревья/кусты
if (this.gdForest) {
try { this.gdForest.dispose(); } catch (e) {}
this.gdForest = null;
}
// Этап G7: снять эффекты с куба игрока
if (this.gdPlayerCube) {
try { this.gdPlayerCube.dispose(); } catch (e) {}
this.gdPlayerCube = null;
}
// Этап G8: trail-частицы
if (this.gdPlayerTrail) {
try { this.gdPlayerTrail.dispose(); } catch (e) {}
this.gdPlayerTrail = null;
}
// Этап G9: пост-обработка (bloom/vignette/освещение)
if (this.gdPostFx) {
try { this.gdPostFx.dispose(); } catch (e) {}
this.gdPostFx = null;
}
// Выключаем оружие
if (this.weapons) {
this.weapons.stop();
}
// Выключаем зомби и спавнеры
if (this.spawnerManager) this.spawnerManager.stop();
if (this.zombieManager) this.zombieManager.stop();
// Выключаем NPC (удаляет их модели и UI).
if (this.npcManager) this.npcManager.stop();
// Выключаем связи объектов.
if (this.constraintManager) this.constraintManager.stop();
// Выключаем лучи и следы.
if (this.beamManager) this.beamManager.stop();
// Выключаем 3D-звук (останавливает активные звуки).
if (this.soundManager) this.soundManager.stop();
if (this.player) {
this.player.stop();
this.player = null;
}
// Возвращаем визуальный маркер спавна
this._setSpawnMarkerVisible(true);
this.primitiveManager?.setTriggersVisible(true);
// Останавливаем физику и возвращаем объекты на исходные позиции
this.dynamics?.stop();
this._restoreDynamicObjects();
// Полный откат сцены: пересоздаём примитивы и модели из снимка —
// возвращаются удалённые скриптом объекты, откатываются цвет/
// видимость/коллизия/повороты, исчезают заспавненные объекты.
this._restoreFullScene();
// Останавливаем фоновый звук
this.audioManager?.stop();
// Восстанавливаем редактор-камеру
const snap = this._editorCameraSnapshot;
// Создаём новую UniversalCamera-редактор (наша старая была уничтожена когда
// PlayerController сделал scene.activeCamera = playerCamera).
// На самом деле она НЕ уничтожилась — мы просто переключали activeCamera.
// Возвращаем её обратно.
this.scene.activeCamera = this.camera;
if (snap) {
this.camera.position = snap.position;
this.camera.rotation = snap.rotation;
}
this._editorCameraSnapshot = null;
}
dispose() {
if (this._resizeHandler) {
window.removeEventListener('resize', this._resizeHandler);
this._resizeHandler = null;
}
if (this._ro) {
this._ro.disconnect();
this._ro = null;
}
for (const { target, type, fn, opts } of this._listeners) {
target.removeEventListener(type, fn, opts);
}
this._listeners = [];
if (this.player) {
this.player.stop();
this.player = null;
}
if (this.history) {
this.history.dispose();
this.history = null;
}
if (this._gizmo) {
try { this._gizmo.dispose(); } catch (e) { /* ignore */ }
this._gizmo = null;
}
if (this._gizmoLayer) {
try { this._gizmoLayer.dispose(); } catch (e) { /* ignore */ }
this._gizmoLayer = null;
}
if (this.selection) {
this.selection.dispose();
this.selection = null;
}
if (this.blockManager) {
this.blockManager.dispose();
this.blockManager = null;
}
if (this.modelManager) {
this.modelManager.dispose();
this.modelManager = null;
}
if (this.primitiveManager) {
this.primitiveManager.dispose();
this.primitiveManager = null;
}
if (this.folderManager) {
this.folderManager.dispose();
this.folderManager = null;
}
if (this.guiManager) {
this.guiManager.clear();
this.guiManager = null;
}
if (this.inventory) {
this.inventory.clear();
this.inventory = null;
}
if (this.dynamics) {
this.dynamics.dispose();
this.dynamics = null;
}
if (this.audioManager) {
this.audioManager.dispose();
this.audioManager = null;
}
if (this.assetManager) {
this.assetManager.dispose();
this.assetManager = null;
}
if (this.soundLibrary) {
this.soundLibrary.dispose();
this.soundLibrary = null;
}
if (this.glbLibrary) {
this.glbLibrary.dispose();
this.glbLibrary = null;
}
this.environment = null;
this.physics = null;
if (this.scene) {
this.scene.dispose();
this.scene = null;
}
if (this.engine) {
this.engine.dispose();
this.engine = null;
}
}
}