/** * PhysicsAABB — простая ручная физика столкновений для voxel-мира. * * AABB (Axis-Aligned Bounding Box) — игрок-капсула с прямоугольной коллизией. * Solver: * 1. Sub-stepping: разбиваем большое перемещение на маленькие шаги * (≤0.25 единицы за шаг) — нельзя пропустить блок при высокой скорости. * 2. На каждом шаге двигаем независимо по осям X → Z → Y и при ударе * прижимаемся к границе блока (slide-along-walls работает естественно). * 3. Floor (y=0) — фиксированная плоскость, ниже не пускаем. * * Это даёт плавное движение без рывков и без проваливаний при беге. */ import { buildOBBAxes, aabbIntersectsOBB } from './OBBCollision'; import { Ray, Vector3 } from '@babylonjs/core'; const SUB_STEP = 0.25; // максимум 0.25 единицы за один шаг const EPS = 0.0001; // микроскопический отступ от границы блока /** * Материалы террейна, СКВОЗЬ которые можно ходить (нет коллизии). * - water: можно плавать, не блокирует движение * - декорации (цветы/гриб/высокая трава/листья): не должны мешать игроку, * как в Minecraft. Иначе персонаж упрётся в каждый кустик/каждое дерево. */ const NON_SOLID_TERRAIN = new Set([ 'water', 'leaves', 'leaves_orange', 'flower_red', 'flower_blue', 'flower_yellow', 'mushroom_red', 'tall_grass', ]); export class PhysicsAABB { constructor(blockManager) { this.blockManager = blockManager; this.primitiveManager = null; this.modelManager = null; this.terrainManager = null; // Размер voxel'а террейна в мире (берётся из TerrainManager при подключении). // Хранится здесь чтобы не делать import — модуль PhysicsAABB должен // оставаться независимым от concrete-движка террейна. this.terrainVoxelSize = 0.5; this.floorY = 0; // Граница видимого пола baseplate: 80x80 (от -40 до 40). // За её пределами пол НЕ работает — игрок проваливается в пустоту. this.floorHalf = 40; // Если false — пол baseplate физически отсутствует, игрок проваливается. this.floorEnabled = true; } /** Подключить PrimitiveManager — его cube/sphere/... тоже участвуют в физике. */ setPrimitiveManager(pm) { this.primitiveManager = pm; } /** Подключить ModelManager — модели тоже могут быть твёрдыми (canCollide). */ setModelManager(mm) { this.modelManager = mm; } /** Подключить UserModelManager (пользовательские voxel-модели). */ setUserModelManager(um) { this.userModelManager = um; } /** Подключить TerrainManager. voxelSize — размер одной ячейки террейна в мире. */ setTerrainManager(tm, voxelSize) { this.terrainManager = tm; if (typeof voxelSize === 'number' && voxelSize > 0) { this.terrainVoxelSize = voxelSize; } } /** Подключить VoxelWorld (Этап 4 voxel-движка) — chunk-based террейн. * Когда установлен — физика проверяет коллизии через voxelWorld.layers.terrain * в дополнение к legacy terrainManager (одно из них может быть пустым). */ setVoxelWorld(world) { this.voxelWorld = world; } /** * Проверить пересечение AABB только с деревьями (без terrain/blocks). * Используется в smooth-terrain ветке X/Z коллизии: если игрок ВНУТРИ * tree-AABB, нельзя пропускать его как "склон под ногами". */ _collidesTreeAt(cx, cy, cz, hw, hh, hd) { if (!this._smoothTreesGrid) return false; const CELL = this._smoothTreesGridCell; const minCx = Math.floor((cx - hw) / CELL); const maxCx = Math.floor((cx + hw) / CELL); const minCz = Math.floor((cz - hd) / CELL); const maxCz = Math.floor((cz + hd) / CELL); const topY = cy + hh; const bottomY = cy - hh; for (let gx = minCx; gx <= maxCx; gx++) { for (let gz = minCz; gz <= maxCz; gz++) { const arr = this._smoothTreesGrid.get(`${gx},${gz}`); if (!arr) continue; for (const t of arr) { const tMinX = t.x - t.halfW; const tMaxX = t.x + t.halfW; const tMinZ = t.z - t.halfD; const tMaxZ = t.z + t.halfD; const tMinY = t.baseY; const tMaxY = t.baseY + t.halfH * 2; if (cx + hw > tMinX && cx - hw < tMaxX && cz + hd > tMinZ && cz - hd < tMaxZ && topY > tMinY && bottomY < tMaxY) { return true; } } } } return false; } /** * Установить список AABB деревьев smooth-decoration. * trees: Array<{x,z, halfW, halfH, halfD, baseY}> где * - (x, baseY, z) — низ цилиндра * - halfW/halfH/halfD — половины размеров AABB * Используется для коллизий — игрок не проходит сквозь деревья. */ setSmoothDecoTrees(trees) { this.smoothDecoTrees = trees || null; if (trees && trees.length > 0) { // Spatial-индекс для быстрой выборки. Cell ~16м (достаточно крупный // чтобы поиск был O(1), но не слишком — иначе много candidates). const CELL = 16; this._smoothTreesGridCell = CELL; const grid = new Map(); for (let i = 0; i < trees.length; i++) { const t = trees[i]; const cx = Math.floor(t.x / CELL); const cz = Math.floor(t.z / CELL); const key = `${cx},${cz}`; let arr = grid.get(key); if (!arr) { arr = []; grid.set(key, arr); } arr.push(t); } this._smoothTreesGrid = grid; } else { this._smoothTreesGrid = null; } } /** Подключить RobloxTerrain (smooth-ландшафт через DensityGrid). * Физика проверяет density-grid: solid ячейка = коллизия. */ setRobloxTerrain(rt) { this.robloxTerrain = rt; console.log('[PhysicsAABB] setRobloxTerrain:', rt ? 'connected' : 'null'); } /** * Найти Y поверхности RobloxTerrain под точкой (worldX, worldZ). * Raycast сверху-вниз через RobloxTerrain mesh-ы. * @returns {number|null} Y или null если нет terrain под точкой. */ _sampleRobloxSurface(worldX, worldZ) { if (!this.robloxTerrain || !this.robloxTerrain.scene) return null; const scene = this.robloxTerrain.scene; const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain); const ray = new Ray( new Vector3(worldX, 1000, worldZ), new Vector3(0, -1, 0), 2000, ); const hit = scene.pickWithRay(ray, pickPred); if (hit && hit.hit && hit.pickedPoint) return hit.pickedPoint.y; return null; } /** * Двигаем AABB на (dx, dy, dz) с sub-stepping. * Возвращает { x, y, z, hitX, hitY, hitZ, onGround }. */ moveAABB(pos, halfW, halfH, halfD, dx, dy, dz) { // Если spatial-индекс устарел — перестраиваем ОДИН раз ЗДЕСЬ, перед // sub-step циклом. Иначе _getSpatialCandidates может среагировать на // dirty-флаг или 50мс-таймер ВНУТРИ bisection и пересобрать grid 568 // примитивов прямо в середине физики прыжка → жёсткие фризы. const SPATIAL_CELL_SIZE = 8; const now = performance.now(); if (!this._spatialBuildAt) this._spatialBuildAt = 0; const hasPrim = this.primitiveManager && this.primitiveManager.instances.size > 0; const hasModel = this.modelManager && this.modelManager.instances.size > 0; const hasUserModel = this.userModelManager && this.userModelManager.instances.size > 0; if ((hasPrim || hasModel || hasUserModel) && (this._spatialDirty || !this._spatialGrid || (now - this._spatialBuildAt) > 50)) { this._buildSpatialGrid(SPATIAL_CELL_SIZE); this._spatialBuildAt = now; } let { x, y, z } = pos; let hitX = false, hitY = false, hitZ = false; // Суммарная высота на которую auto-step «телепортировал» игрока // за этот вызов moveAABB. PlayerController использует это значение // чтобы плавно интерполировать визуальный mesh (без неё камера // и аватар «дёргаются» вверх рывком). let steppedUpBy = 0; // Высота на которую игрок автоматически залезает на препятствие // если движется горизонтально и упёрся в стену. Аналог Roblox/Minecraft // «полупрыжок» — позволяет бежать через voxel-террейн и низкие блоки // без застревания на каждой ступеньке. // // 0.55м = чуть выше 0.5м (один voxel или один блок). При желании можно // поднять до 1.05м (два voxel'а), но это делает игрока «летающим» // через низкие препятствия — менее естественно. // // Применяется ТОЛЬКО когда: // 1. Игрок на земле (wasOnGround) — нельзя степать в прыжке // 2. Игрок движется ВНИЗ или ровно (sy <= 0) — нельзя степать // когда прыгаем вверх // 3. Препятствие имеет горизонтальный край ≤ stepHeight const STEP_UP_MAX = 0.55; // Проверка «был на земле в начале» — стандартный нижний raycast // (тот же что в конце функции для onGround результата) const wasOnGround = !this._collidesAt(x, y, z, halfW, halfH, halfD) && this._collidesAt(x, y - 0.05, z, halfW, halfH, halfD); // === UNSTUCK для smooth terrain: если raycast в текущей точке даёт // surface ВЫШЕ нашего bottomY — значит мы провалились в землю. // Просто телепортируемся на поверхность (как auto-spawn). // ВАЖНО: делается до общего UNSTUCK ниже, потому что raycast в этой // ситуации делает каждый _collidesAt true, и общий UNSTUCK не находит // выход через "up"/"horizontal". if (this.robloxTerrain && this.robloxTerrain.grid && this.robloxTerrain.scene) { const sY = this._sampleRobloxSurface(x, z); if (sY !== null && sY > y - halfH + 0.1) { // Поверхность выше bottomY → провалились. Поднимаемся на surface. y = sY + halfH + 0.01; } } // === UNSTUCK: если игрок УЖЕ застрял в препятствии (например движущаяся // платформа сдвинулась через него за прошлый кадр) — выталкиваем по // ближайшей оси, чтобы он мог двигаться. // ВАЖНО: для tree-collision НЕ перебираем 6 направлений (это // телепортировало игрока на ±3м вбок/назад). Вместо этого // выталкиваем по направлению ОТ ствола (centroid → player). if (this._collidesAt(x, y, z, halfW, halfH, halfD)) { const stuckInTree = this._collidesTreeAt(x, y, z, halfW, halfH, halfD); if (stuckInTree && this._smoothTreesGrid) { // Находим ближайшее дерево и выталкиваем в радиальном направлении const CELL = this._smoothTreesGridCell; const minCx = Math.floor((x - halfW) / CELL); const maxCx = Math.floor((x + halfW) / CELL); const minCz = Math.floor((z - halfD) / CELL); const maxCz = Math.floor((z + halfD) / CELL); let bestT = null; let bestD2 = Infinity; for (let gx = minCx; gx <= maxCx; gx++) { for (let gz = minCz; gz <= maxCz; gz++) { const arr = this._smoothTreesGrid.get(`${gx},${gz}`); if (!arr) continue; for (const t of arr) { const dx2 = t.x - x; const dz2 = t.z - z; const d2 = dx2 * dx2 + dz2 * dz2; if (d2 < bestD2) { bestD2 = d2; bestT = t; } } } } if (bestT) { // Направление от центра дерева к игроку let dx2 = x - bestT.x; let dz2 = z - bestT.z; const dlen = Math.sqrt(dx2 * dx2 + dz2 * dz2); if (dlen < 0.01) { dx2 = 1; dz2 = 0; } // fallback если точно в центре else { dx2 /= dlen; dz2 /= dlen; } // Выталкиваем небольшими шагами 0.05м, максимум 1м const PS = 0.05; for (let k = 1; k <= 20; k++) { const tx = x + dx2 * k * PS; const tz = z + dz2 * k * PS; if (!this._collidesAt(tx, y, tz, halfW, halfH, halfD)) { x = tx; z = tz; break; } } } } else { // Стандартный 6-directional UNSTUCK для блоков/платформ. const PUSH_STEP = 0.1; const PUSH_MAX = 30; const dirs = [ [0, 1, 0], [1, 0, 0], [-1, 0, 0], [0, 0, 1], [0, 0, -1], [0, -1, 0], ]; let pushed = false; for (const [ux, uy, uz] of dirs) { for (let k = 1; k <= PUSH_MAX; k++) { const tx = x + ux * k * PUSH_STEP; const ty = y + uy * k * PUSH_STEP; const tz = z + uz * k * PUSH_STEP; if (!this._collidesAt(tx, ty, tz, halfW, halfH, halfD)) { x = tx; y = ty; z = tz; pushed = true; break; } } if (pushed) break; } } } // Считаем сколько sub-шагов нужно const maxComponent = Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz)); const steps = Math.max(1, Math.ceil(maxComponent / SUB_STEP)); const sx = dx / steps; const sy = dy / steps; const sz = dz / steps; // Helper для step-up. При блокировании горизонтального движения // пробуем поднять AABB на STEP_UP_MAX. Если на этой высоте по // тому же направлению нет препятствия — значит это лестница/уступ, // делаем «полупрыжок»: возвращаем новую Y. // // Возвращает {ok: true, newY} если step-up удался, {ok: false} иначе. // tryX/tryZ — пробное продвижение по соответствующей оси (то что мы // пытались сделать но упёрлись). const tryStepUp = (cx, cy, cz, tryX, tryZ) => { if (!wasOnGround) return { ok: false }; if (sy > 0) return { ok: false }; // прыгаем вверх — не степаем // Перебираем высоту от маленькой к большой (бинарный поиск избыточен, // достаточно фиксированных шагов: 0.15, 0.30, 0.45, 0.55м) const heights = [0.15, 0.30, 0.45, STEP_UP_MAX]; for (const h of heights) { const nyTop = cy + h; // Сначала убедимся что на новой высоте «голова» не упирается в потолок if (this._collidesAt(cx, nyTop, cz, halfW, halfH, halfD)) continue; // Затем — на новой высоте можно продвинуться вперёд? if (this._collidesAt(cx + tryX, nyTop, cz + tryZ, halfW, halfH, halfD)) continue; // Можно. Проверяем что после step-up игрок «приземлится» на // что-то (не висит в воздухе) — иначе мы запрыгнули на // потолок узкой щели. if (!this._collidesAt(cx + tryX, nyTop - h - 0.05, cz + tryZ, halfW, halfH, halfD)) { // Под нами пустота на полную высоту степа — значит // это не уступ, а пролёт. Пропускаем. continue; } return { ok: true, newY: nyTop, dx: tryX, dz: tryZ }; } return { ok: false }; }; // Флаг: используем surface-follow для smooth terrain (raycast-based). // ВАЖНО: НЕ требуем wasOnGround. Хватит того что: // а) есть smooth-terrain // б) игрок не прыгает (sy <= 0, т.е. не движется вверх) // в) поверхность РЯДОМ (в пределах STEP_UP_MAX от bottomY) // // wasOnGround не подходит: после прошлого surface-follow гравитация // даёт y ниже surface → _collidesAt(y) возвращает true → wasOnGround=false // → surface-follow ОТКЛЮЧАЕТСЯ → vy копится → onGround мерцает → // animation walk↔sprint каждый кадр (как видно в [Anim] логе). let useSurfaceFollow = false; if (this.robloxTerrain && this.robloxTerrain.grid && this.robloxTerrain.scene && sy <= 0) { const surfYStart = this._sampleRobloxSurface(x, z); if (surfYStart !== null) { const distToSurface = (y - halfH) - surfYStart; // Если ноги в пределах STEP_UP_MAX от поверхности → мы на склоне. // Включает случай "только что приземлился" (distToSurface отрицателен) // и "чуть-чуть оторвался" (positive but small). if (distToSurface > -STEP_UP_MAX && distToSurface < STEP_UP_MAX) { useSurfaceFollow = true; } } } for (let i = 0; i < steps; i++) { // === X === if (sx !== 0) { const nx = x + sx; // На smooth-terrain: НЕ применяем horizontal-collision если // single-point pickWithRay в новой точке даёт surface ниже // top AABB (т.е. это просто склон, не стена). В этом случае // принимаем движение, surface-follow внизу подгонит Y. // ВАЖНО: если коллизия С ДЕРЕВОМ (tree-AABB), surface-follow // НЕ обходит её — иначе игрок проскальзывает сквозь стволы. let horizBlocked = false; if (this._collidesAt(nx, y, z, halfW, halfH, halfD)) { const hitTree = this._collidesTreeAt(nx, y, z, halfW, halfH, halfD); if (useSurfaceFollow && !hitTree) { // Пробуем surface в новой точке const sY = this._sampleRobloxSurface(nx, z); if (sY !== null && sY < y + halfH - 0.1) { // Surface ниже головы → проходим, Y подгоним позже. x = nx; } else { horizBlocked = true; } } else { horizBlocked = true; } } else { x = nx; } if (horizBlocked) { // Попытка step-up: может это просто уступ перед нами. // НО для деревьев step-up отключаем — нельзя залезать на ствол. const onTreeBlock = this._collidesTreeAt(x + sx, y, z, halfW, halfH, halfD); const su = onTreeBlock ? { ok: false } : tryStepUp(x, y, z, sx, 0); if (su.ok) { steppedUpBy += su.newY - y; x = nx; y = su.newY; } else { let lo = 0, hi = sx; for (let k = 0; k < 8; k++) { const mid = (lo + hi) / 2; if (this._collidesAt(x + mid, y, z, halfW, halfH, halfD)) hi = mid; else lo = mid; } // Backoff: для дерева отступаем на 0.02м от границы. // Иначе следующий кадр UNSTUCK сочтёт игрока застрявшим // и телепортирует наружу (вправо/влево/назад на ±3м). if (onTreeBlock) { const sign = sx > 0 ? 1 : -1; lo = Math.max(0, lo - 0.02) * sign / Math.abs(sign || 1); // ↑ упрощённо: уменьшаем модуль на 0.02 } x += lo; hitX = true; } } } // === Z === if (sz !== 0) { const nz = z + sz; let horizBlocked = false; if (this._collidesAt(x, y, nz, halfW, halfH, halfD)) { const hitTree = this._collidesTreeAt(x, y, nz, halfW, halfH, halfD); if (useSurfaceFollow && !hitTree) { const sY = this._sampleRobloxSurface(x, nz); if (sY !== null && sY < y + halfH - 0.1) { z = nz; } else { horizBlocked = true; } } else { horizBlocked = true; } } else { z = nz; } if (horizBlocked) { const onTreeBlock = this._collidesTreeAt(x, y, z + sz, halfW, halfH, halfD); const su = onTreeBlock ? { ok: false } : tryStepUp(x, y, z, 0, sz); if (su.ok) { steppedUpBy += su.newY - y; z = nz; y = su.newY; } else { let lo = 0, hi = sz; for (let k = 0; k < 8; k++) { const mid = (lo + hi) / 2; if (this._collidesAt(x, y, z + mid, halfW, halfH, halfD)) hi = mid; else lo = mid; } if (onTreeBlock) { lo = Math.max(0, Math.abs(lo) - 0.02) * (sz > 0 ? 1 : -1); } z += lo; hitZ = true; } } } // === Y === if (sy !== 0) { const ny = y + sy; if (this._collidesAt(x, ny, z, halfW, halfH, halfD)) { let lo = 0, hi = sy; for (let k = 0; k < 8; k++) { const mid = (lo + hi) / 2; if (this._collidesAt(x, y + mid, z, halfW, halfH, halfD)) hi = mid; else lo = mid; } y += lo; hitY = true; } else { y = ny; } } } // === Snap-down (для voxel-step-up) === // Если игрок был на земле и НЕ прыгает (sy <= 0) — после движения // ищем поверхность под ногами в диапазоне до STEP_UP_MAX вниз. // Работает для voxel-уступов (smooth terrain уже обработан выше). if (wasOnGround && sy <= 0) { const maxSnap = STEP_UP_MAX + 0.05; if (!this._collidesAt(x, y - 0.01, z, halfW, halfH, halfD)) { let lo = 0, hi = maxSnap; for (let k = 0; k < 8; k++) { const mid = (lo + hi) / 2; if (this._collidesAt(x, y - mid, z, halfW, halfH, halfD)) hi = mid; else lo = mid; } if (lo > 0) y -= lo; } } // === Surface-follow для smooth terrain === // surfaceFollowed = true → PlayerController обнулит velocityY, // иначе гравитация будет копиться → вибрация на склоне. let surfaceFollowed = false; if (useSurfaceFollow) { const isMoving = (dx !== 0) || (dz !== 0); const surfY = this._sampleRobloxSurface(x, z); if (surfY !== null) { const desiredY = surfY + halfH; const delta = desiredY - y; if (isMoving) { if (delta > -STEP_UP_MAX * 2 && delta < STEP_UP_MAX + 0.05) { y = desiredY; surfaceFollowed = true; } } else { if (delta > 0.1 && delta < STEP_UP_MAX + 0.05) { y = desiredY; surfaceFollowed = true; } } } } // === Пол baseplate (y=0) === // Если AABB опустилась ниже floorY+halfH — поднимаем, // но только в пределах видимого baseplate'а. За его границами // игрок проваливается в пустоту (как в Roblox). if (this.floorEnabled) { const onBaseplate = Math.abs(x) <= this.floorHalf && Math.abs(z) <= this.floorHalf; if (onBaseplate && y - halfH < this.floorY) { y = this.floorY + halfH + EPS; hitY = true; } } // === Определяем onGround === // Игрок на земле если чуть-чуть ниже AABB есть препятствие. // ВАЖНО: при surface-follow на smooth-terrain считаем onGround=true // безусловно — иначе float-сравнения в raycast дают мерцание true/false, // PlayerController меняет анимацию idle→jump→walk → ноги дёргаются. const onGround = surfaceFollowed || this._collidesAt(x, y - 0.05, z, halfW, halfH, halfD); // === Определяем onCeiling === // Игрок «приземлился» к потолку если чуть-чуть выше AABB есть препятствие. // Используется только в режиме перевёрнутой гравитации (Кубикон Dash // после blueOrb/gravityPortal). Когда gDir=-1, потолок становится «полом». const onCeiling = this._collidesAt(x, y + 0.05, z, halfW, halfH, halfD); // Если стоим на чём-то — пробуем определить ИМЕННО на каком объекте // (блок/примитив/модель). Для движущихся платформ это нужно чтобы // PlayerController смог трекать их положение и двигать игрока вместе. // При onCeiling берём «потолок» как ground-objet (для stick-эффекта). let groundData = null; if (onGround) { groundData = this._findGroundCandidate(x, y - 0.05, z, halfW, halfH, halfD); } else if (onCeiling) { groundData = this._findGroundCandidate(x, y + 0.05, z, halfW, halfH, halfD); } return { x, y, z, hitX, hitY, hitZ, onGround, onCeiling, groundData, steppedUpBy, surfaceFollowed }; } /** * Найти data объекта под AABB (если он не блок-сетка). Возвращает * { kind: 'primitive'|'model', data } или null. Используется для * «прилипания» игрока к движущимся платформам. * Блоки воксельной сетки не двигаются — для них null (стандартное поведение). */ _findGroundCandidate(cx, cy, cz, hw, hh, hd) { const candidates = this._getSpatialCandidates(cx, cz, hw, hd); if (!candidates) return null; // Низ AABB игрока (вызывающий код передал cy = y - 0.05, поэтому // фактический низ ноги игрока ≈ cy + 0.05 - hh). const playerBottom = cy + 0.05 - hh; // Допустимый зазор между ногой игрока и верхом платформы — 0.25м. // Если больше — значит игрок касается БОКА платформы, а не стоит // на ней сверху. В этом случае нельзя считать платформу «грунтом» // (иначе stick-механика тащит игрока в бок и он застревает). const TOP_TOLERANCE = 0.25; for (const item of candidates) { if (item.kind === 'primitive') { const data = item.data; if (data.canCollide === false) continue; if (data.type === 'trigger' || data.type === 'checkpoint') continue; if (!this._aabbIntersectsPrimitive(cx, cy, cz, hw, hh, hd, data)) continue; // Верх примитива (без учёта вращения — для платформ с // rotation это приближение, но для типичных платформ ОК). const platTop = (data.y || 0) + (data.sy || 1) * 0.5; if (Math.abs(playerBottom - platTop) <= TOP_TOLERANCE) { return { kind: 'primitive', data }; } // Иначе — это бок/верх не совпадает; игнорируем как ground. } else if (item.kind === 'model') { const data = item.data; if (data.canCollide === false) continue; if (!this._aabbIntersectsModel(cx, cy, cz, hw, hh, hd, data)) continue; const local = data.localAABB; const platTop = (data.y || 0) + ((local && local.maxY) ?? 1); if (Math.abs(playerBottom - platTop) <= TOP_TOLERANCE) { return { kind: 'model', data }; } } } return null; } /** * Проверка пересечения AABB c voxel-блоками или полом. */ _collidesAt(cx, cy, cz, hw, hh, hd) { // Пол baseplate — действует только в пределах видимого квадрата 80x80. // За его границами «пола нет», игрок свободно падает. if (this.floorEnabled) { const onBaseplate = Math.abs(cx) <= this.floorHalf && Math.abs(cz) <= this.floorHalf; if (onBaseplate && cy - hh < this.floorY - EPS) return true; } // Если blockManager отсутствует — пропускаем блок-проверку, но // terrain/primitives/modelы всё равно надо проверять ниже. if (this.blockManager) { // Координаты клеток которые AABB может пересекать. // Блок (gx) занимает X: gx-0.5..gx+0.5. AABB X: cx-hw..cx+hw. // Пересечение если gx-0.5 < cx+hw И gx+0.5 > cx-hw → gx-0.5 < cx+hw, gx+0.5 > cx-hw. // → gx > cx-hw-0.5, gx < cx+hw+0.5 → gxMin = ceil(cx-hw-0.5+EPS), gxMax = floor(cx+hw+0.5-EPS). // Для надёжности используем floor границ: const gxMin = Math.floor(cx - hw + 0.5 + EPS); const gxMax = Math.floor(cx + hw + 0.5 - EPS); const gzMin = Math.floor(cz - hd + 0.5 + EPS); const gzMax = Math.floor(cz + hd + 0.5 - EPS); // Блок (gy) занимает Y: gy..gy+1. AABB Y: cy-hh..cy+hh. // Пересечение если gy < cy+hh, gy+1 > cy-hh. const gyMin = Math.floor(cy - hh + EPS); const gyMax = Math.floor(cy + hh - EPS); for (let gx = gxMin; gx <= gxMax; gx++) { for (let gy = gyMin; gy <= gyMax; gy++) { if (gy < 0) continue; for (let gz = gzMin; gz <= gzMax; gz++) { const blockMesh = this.blockManager.blocks.get(`${gx},${gy},${gz}`); if (!blockMesh) continue; // Unanchored блоки в Play двигаются независимо — их клетка // больше не статичное препятствие. Они проверяются ниже как // dynamic body по реальной позиции mesh. if (blockMesh.metadata?.anchored === false) continue; if (blockMesh.metadata?.canCollide === false) continue; return true; } } } } // end if (this.blockManager) // Voxel-террейн. Каждая ячейка — куб со стороной voxelSize, не 1. // У террейна voxel-индексы x,y,z целые, мировой куб занимает // [x*S..(x+1)*S] по каждой оси (где S = voxelSize). // AABB X: cx-hw..cx+hw → пересекает voxel-индексы: // gxMin = floor((cx - hw + EPS) / S) // gxMax = floor((cx + hw - EPS) / S) // Аналогично для Y/Z. Никакого +0.5 как для блоков — там 1×1×1. if (this.terrainManager && this.terrainManager.voxels?.size) { const S = this.terrainVoxelSize; const tgxMin = Math.floor((cx - hw + EPS) / S); const tgxMax = Math.floor((cx + hw - EPS) / S); const tgyMin = Math.floor((cy - hh + EPS) / S); const tgyMax = Math.floor((cy + hh - EPS) / S); const tgzMin = Math.floor((cz - hd + EPS) / S); const tgzMax = Math.floor((cz + hd - EPS) / S); const tvox = this.terrainManager.voxels; for (let gx = tgxMin; gx <= tgxMax; gx++) { for (let gy = tgyMin; gy <= tgyMax; gy++) { if (gy < 0) continue; for (let gz = tgzMin; gz <= tgzMax; gz++) { // Воду пропускаем — сквозь неё можно ходить (можно // плавать; коллизия воды слишком ограничит игрока). // Декорации (цветы/гриб/высокая трава/листья) — тоже // проходим насквозь, как в Minecraft: они не должны // мешать движению, иначе игрок упрётся в кустик. const mat = tvox.get(`${gx},${gy},${gz}`); if (!mat) continue; if (NON_SOLID_TERRAIN.has(mat)) continue; return true; } } } } // RobloxTerrain — scene.pickWithRay по центру AABB. // // Раньше пробовали heightmap-cache, но vertex'ы Surface Nets // разреженные — heightmap имел дыры (-Infinity) и коллизии // отключались. Возвращаемся к raycast — он надёжен, а на 1 // точке достаточно дёшев (~100мкс). // // 1 точка (центр AABB) + step-up + snap-down достаточны для // ходьбы по холмам. if (this.robloxTerrain && this.robloxTerrain.grid && this.robloxTerrain.scene) { const scene = this.robloxTerrain.scene; const topY = cy + hh; const bottomY = cy - hh; const rayStartY = topY + 50; const rayLen = rayStartY - (bottomY - 0.5); const rayDir = new Vector3(0, -1, 0); const pickPred = (m) => !!(m.metadata && m.metadata._isRobloxTerrain); const ray = new Ray(new Vector3(cx, rayStartY, cz), rayDir, rayLen); const hit = scene.pickWithRay(ray, pickPred); if (hit && hit.hit && hit.pickedPoint) { if (hit.pickedPoint.y > bottomY) return true; } } // Smooth-deco trees — узкие AABB-цилиндры вокруг стволов. // Используем spatial grid (16м-cells) чтобы не перебирать всё дерево. if (this._smoothTreesGrid) { const CELL = this._smoothTreesGridCell; const minCx = Math.floor((cx - hw) / CELL); const maxCx = Math.floor((cx + hw) / CELL); const minCz = Math.floor((cz - hd) / CELL); const maxCz = Math.floor((cz + hd) / CELL); const topY = cy + hh; const bottomY = cy - hh; for (let gx = minCx; gx <= maxCx; gx++) { for (let gz = minCz; gz <= maxCz; gz++) { const arr = this._smoothTreesGrid.get(`${gx},${gz}`); if (!arr) continue; for (const t of arr) { // AABB-overlap: проверяем XZ + Y вертикальное пересечение const tMinX = t.x - t.halfW; const tMaxX = t.x + t.halfW; const tMinZ = t.z - t.halfD; const tMaxZ = t.z + t.halfD; const tMinY = t.baseY; const tMaxY = t.baseY + t.halfH * 2; if (cx + hw > tMinX && cx - hw < tMaxX && cz + hd > tMinZ && cz - hd < tMaxZ && topY > tMinY && bottomY < tMaxY) { return true; } } } } } // VoxelWorld (Этап 4 voxel-движка) — chunk-based террейн. // Когда генератор использовал WorldGenerator → terrain в чанках, // а не в legacy terrainManager.voxels. Проверяем коллизии тут. if (this.voxelWorld) { const terrainLayer = this.voxelWorld.getLayer('terrain'); if (terrainLayer && terrainLayer.chunks.size > 0) { const S = terrainLayer.voxelSize; const vgxMin = Math.floor((cx - hw + EPS) / S); const vgxMax = Math.floor((cx + hw - EPS) / S); const vgyMin = Math.floor((cy - hh + EPS) / S); const vgyMax = Math.floor((cy + hh - EPS) / S); const vgzMin = Math.floor((cz - hd + EPS) / S); const vgzMax = Math.floor((cz + hd - EPS) / S); for (let gx = vgxMin; gx <= vgxMax; gx++) { for (let gy = vgyMin; gy <= vgyMax; gy++) { if (gy < 0) continue; for (let gz = vgzMin; gz <= vgzMax; gz++) { const matId = terrainLayer.getVoxel(gx, gy, gz); if (!matId) continue; if (NON_SOLID_TERRAIN.has(matId)) continue; return true; } } } } } // Unanchored блоки — их позиция уже не привязана к клетке (они летают // как тела). Проверяем AABB-vs-AABB по реальной mesh.position. // ОПТИМИЗАЦИЯ: BlockManager поддерживает Set _unanchoredBlocks — обходим // только их (обычно 0..несколько штук), а не всю Map (~500 блоков). if (this.blockManager?._unanchoredBlocks?.size) { for (const mesh of this.blockManager._unanchoredBlocks) { if (!mesh.metadata) continue; if (mesh.metadata.canCollide === false) continue; const bx = mesh.position.x, by = mesh.position.y, bz = mesh.position.z; if (cx + hw > bx - 0.5 && cx - hw < bx + 0.5 && cy + hh > by - 0.5 && cy - hh < by + 0.5 && cz + hd > bz - 0.5 && cz - hd < bz + 0.5) return true; } } // Примитивы и модели — обходим через spatial-bucket grid. // Без него каждый sub-step делает O(N) пробег по ВСЕМ инстансам, // что на средней карте (50+ примитивов + 10+ моделей) даёт сотни // итераций per-frame только из физики игрока. const candidates = this._getSpatialCandidates(cx, cz, hw, hd); if (candidates) { for (const item of candidates) { if (item.kind === 'primitive') { const data = item.data; if (data.type === 'trigger' || data.type === 'checkpoint') continue; if (data.canCollide === false) continue; if (this._aabbIntersectsPrimitive(cx, cy, cz, hw, hh, hd, data)) { return true; } } else if (item.kind === 'model') { const data = item.data; if (data.canCollide === false) continue; if (this._aabbIntersectsModel(cx, cy, cz, hw, hh, hd, data)) { return true; } } else if (item.kind === 'userModel') { const data = item.data; if (data.canCollide === false) continue; if (this._aabbIntersectsUserModel(cx, cy, cz, hw, hh, hd, data)) { return true; } } } } return false; } /** AABB-vs-voxel пересечение игрока с пользовательской voxel-моделью. * * Двухфазная проверка: * 1. BROAD — быстрый AABB-vs-AABB по общему bbox модели. 99% кадров * игрок не касается модели вообще → мгновенный выход. * 2. NARROW — если broad прошёл И есть voxelMask, проверяется * пересечение с КОНКРЕТНЫМИ занятыми вокселями. Это позволяет * заходить в проёмы / ходить по «дорожке» арочного моста — там, * где раньше был сплошной AABB-короб. * * Производительность narrow-фазы: итерируются только воксели в пределах * AABB игрока (на касании это 1-3 слоя), не вся модель. Маска — * Uint8Array, доступ O(1). Учитывается scale и rotationY. */ _aabbIntersectsUserModel(cx, cy, cz, hw, hh, hd, data) { const local = data.localAABB || { minX: -0.5, maxX: 0.5, minY: 0, maxY: 1, minZ: -0.5, maxZ: 0.5 }; const s = data.scale || 1; // Умножаем local-bbox на scale (равномерный) let lminX = local.minX * s, lmaxX = local.maxX * s; let lminY = local.minY * s, lmaxY = local.maxY * s; let lminZ = local.minZ * s, lmaxZ = local.maxZ * s; // Учитываем rotationY — расширяем XZ-bbox по 4 углам. const rotY = data.rotationY || 0; if (rotY !== 0) { const c = Math.cos(rotY), si = Math.sin(rotY); const corners = [ { x: lminX, z: lminZ }, { x: lmaxX, z: lminZ }, { x: lmaxX, z: lmaxZ }, { x: lminX, z: lmaxZ }, ]; let nminX = Infinity, nmaxX = -Infinity, nminZ = Infinity, nmaxZ = -Infinity; for (const cr of corners) { const rx = cr.x * c - cr.z * si; const rz = cr.x * si + cr.z * c; if (rx < nminX) nminX = rx; if (rx > nmaxX) nmaxX = rx; if (rz < nminZ) nminZ = rz; if (rz > nmaxZ) nmaxZ = rz; } lminX = nminX; lmaxX = nmaxX; lminZ = nminZ; lmaxZ = nmaxZ; } const minX = data.x + lminX, maxX = data.x + lmaxX; const minY = data.y + lminY, maxY = data.y + lmaxY; const minZ = data.z + lminZ, maxZ = data.z + lmaxZ; // BROAD: не пересекает общий bbox → точно нет коллизии. const broadHit = ( cx + hw > minX && cx - hw < maxX && cy + hh > minY && cy - hh < maxY && cz + hd > minZ && cz - hd < maxZ ); if (!broadHit) return false; // NARROW: есть воксельная маска → проверяем конкретные воксели. const mask = data.voxelMask; if (!mask || mask.count === 0) { // Маски нет (старая модель / битый data) — fallback на broad AABB. return true; } return this._aabbIntersectsVoxelMask(cx, cy, cz, hw, hh, hd, data, mask, s, rotY); } /** NARROW-фаза: пересечение AABB игрока с воксельной маской модели. * * Алгоритм: * - Переводим AABB игрока в ЛОКАЛЬНУЮ систему модели (вычитаем * позицию инстанса, применяем обратный поворот вокруг Y, делим * на scale → получаем диапазон в "мировых-без-трансформа" единицах). * - Конвертируем в воксельные индексы (через VOXEL_SIZE_USER_MODEL). * - Пробегаем только воксели в этом диапазоне; первый занятый = hit. * * Размер voxel совпадает с UserModelManager.VOXEL_SIZE (0.0625 м). */ _aabbIntersectsVoxelMask(cx, cy, cz, hw, hh, hd, data, mask, s, rotY) { const VS = 0.0625; // VOXEL_SIZE — совпадает с UserModelManager // AABB игрока в мировых координатах. let pMinX = cx - hw, pMaxX = cx + hw; const pMinY = cy - hh, pMaxY = cy + hh; let pMinZ = cz - hd, pMaxZ = cz + hd; // Переводим в систему координат модели ДО масштаба и поворота. // rootNode.position = (data.x, data.y, data.z), rotation.y = rotY, // scaling = s. Меш-вершины модели = voxelIndex * VS. // Значит мировая точка P → локальная: invRot((P - pos)) / s. const ox = data.x, oy = data.y, oz = data.z; // Сначала вычитаем позицию. let aMinX = pMinX - ox, aMaxX = pMaxX - ox; const aMinY = pMinY - oy, aMaxY = pMaxY - oy; let aMinZ = pMinZ - oz, aMaxZ = pMaxZ - oz; // Обратный поворот вокруг Y: расширяем XZ-диапазон по 4 углам // (повёрнутый AABB → axis-aligned обёртка в локальной системе). if (rotY !== 0) { const c = Math.cos(-rotY), si = Math.sin(-rotY); const corners = [ [aMinX, aMinZ], [aMaxX, aMinZ], [aMaxX, aMaxZ], [aMinX, aMaxZ], ]; let nMinX = Infinity, nMaxX = -Infinity, nMinZ = Infinity, nMaxZ = -Infinity; for (const cr of corners) { const rx = cr[0] * c - cr[1] * si; const rz = cr[0] * si + cr[1] * c; if (rx < nMinX) nMinX = rx; if (rx > nMaxX) nMaxX = rx; if (rz < nMinZ) nMinZ = rz; if (rz > nMaxZ) nMaxZ = rz; } aMinX = nMinX; aMaxX = nMaxX; aMinZ = nMinZ; aMaxZ = nMaxZ; } // Делим на масштаб → координаты в "единицах вершин модели". const invS = 1 / s; // Конвертируем в воксельные индексы. Вершина voxel'а v занимает // [v*VS, (v+1)*VS]. Индекс в mask.grid = voxelIndex - mask.minX и т.д. let vx0 = Math.floor((aMinX * invS) / VS) - mask.minX; let vx1 = Math.floor((aMaxX * invS) / VS) - mask.minX; let vy0 = Math.floor((aMinY * invS) / VS) - mask.minY; let vy1 = Math.floor((aMaxY * invS) / VS) - mask.minY; let vz0 = Math.floor((aMinZ * invS) / VS) - mask.minZ; let vz1 = Math.floor((aMaxZ * invS) / VS) - mask.minZ; // Клампим к границам сетки. if (vx1 < 0 || vy1 < 0 || vz1 < 0) return false; if (vx0 >= mask.sx || vy0 >= mask.sy || vz0 >= mask.sz) return false; if (vx0 < 0) vx0 = 0; if (vy0 < 0) vy0 = 0; if (vz0 < 0) vz0 = 0; if (vx1 >= mask.sx) vx1 = mask.sx - 1; if (vy1 >= mask.sy) vy1 = mask.sy - 1; if (vz1 >= mask.sz) vz1 = mask.sz - 1; const grid = mask.grid; const sx = mask.sx, sz = mask.sz; // Пробег по вокселям в диапазоне AABB игрока — первый занятый = hit. for (let vy = vy0; vy <= vy1; vy++) { const yBase = vy * sz; for (let vz = vz0; vz <= vz1; vz++) { const zBase = (yBase + vz) * sx; for (let vx = vx0; vx <= vx1; vx++) { if (grid[zBase + vx] !== 0) return true; } } } return false; } /** * Spatial-bucket grid для primitives и models. Ячейка = SPATIAL_CELL_SIZE * мировых единиц (8 по умолчанию). Объект может попасть в несколько ячеек, * если перекрывает их границы. Хранится lazy: первый запрос строит индекс, * setSpatialDirty() сбрасывает его при изменениях. * * Возвращает Set candidates пересекающих AABB вокруг (cx, cz, hw, hd) или * null если индекс не нужен (нет ни primitives ни models — fast path). */ _getSpatialCandidates(cx, cz, hw, hd) { const hasPrim = this.primitiveManager && this.primitiveManager.instances.size > 0; const hasModel = this.modelManager && this.modelManager.instances.size > 0; const hasUserModel = this.userModelManager && this.userModelManager.instances.size > 0; if (!hasPrim && !hasModel && !hasUserModel) return null; // Rebuild делается в moveAABB перед sub-step циклом (один раз за кадр). // Здесь только lazy-init на самый первый вызов (вне moveAABB — например, // getOverlappingPrimitives при триггерах). const SPATIAL_CELL_SIZE = 8; if (!this._spatialGrid) { this._buildSpatialGrid(SPATIAL_CELL_SIZE); this._spatialBuildAt = performance.now(); } const grid = this._spatialGrid; const cs = SPATIAL_CELL_SIZE; const x0 = Math.floor((cx - hw) / cs); const x1 = Math.floor((cx + hw) / cs); const z0 = Math.floor((cz - hd) / cs); const z1 = Math.floor((cz + hd) / cs); // Используем переиспользуемый Set для аккумуляции — экономит GC if (!this._spatialQuerySet) this._spatialQuerySet = new Set(); const out = this._spatialQuerySet; out.clear(); for (let gx = x0; gx <= x1; gx++) { for (let gz = z0; gz <= z1; gz++) { const cell = grid.get(gx * 100003 + gz); if (!cell) continue; for (const item of cell) out.add(item); } } return out; } /** * Сбросить кэш spatial-grid — следующий moveAABB пересоберёт его. * Нужно когда у примитива сменился canCollide в рантайме (Фаза 5.9): * иначе grid держит старое состояние до 50мс и UNSTUCK не видит * объект, ставший твёрдым. */ invalidateSpatialGrid() { this._spatialBuildAt = 0; } _buildSpatialGrid(cellSize) { const grid = new Map(); const addToCells = (item, minX, maxX, minZ, maxZ) => { const x0 = Math.floor(minX / cellSize); const x1 = Math.floor(maxX / cellSize); const z0 = Math.floor(minZ / cellSize); const z1 = Math.floor(maxZ / cellSize); for (let gx = x0; gx <= x1; gx++) { for (let gz = z0; gz <= z1; gz++) { const key = gx * 100003 + gz; let cell = grid.get(key); if (!cell) { cell = []; grid.set(key, cell); } cell.push(item); } } }; if (this.primitiveManager) { for (const data of this.primitiveManager.instances.values()) { // Не индексируем декоративные примитивы (canCollide=false) — // они никогда не участвуют в коллизиях игрока. // На больших сценах с сотнями декораций это даёт огромный // выигрыш по FPS (раньше каждая большая декоративная сфера // занимала десятки клеток индекса и засоряла поиск). if (data.canCollide === false) continue; // Используем приближённый AABB по позиции и размерам, с // запасом 3м для движущихся платформ. const r = Math.max(data.sx || 1, data.sy || 1, data.sz || 1) * 0.5 + 3; addToCells({ kind: 'primitive', data }, data.x - r, data.x + r, data.z - r, data.z + r); } } if (this.modelManager) { for (const data of this.modelManager.instances.values()) { if (data.canCollide === false) continue; const r = 2; addToCells({ kind: 'model', data }, data.x - r, data.x + r, data.z - r, data.z + r); } } if (this.userModelManager) { for (const data of this.userModelManager.instances.values()) { if (data.canCollide === false) continue; // Радиус по реальному localAABB (с учётом scale) let r = 2; if (data.localAABB) { const s = data.scale || 1; const dx = Math.max(Math.abs(data.localAABB.minX), Math.abs(data.localAABB.maxX)) * s; const dz = Math.max(Math.abs(data.localAABB.minZ), Math.abs(data.localAABB.maxZ)) * s; r = Math.max(dx, dz); } addToCells({ kind: 'userModel', data }, data.x - r, data.x + r, data.z - r, data.z + r); } } this._spatialGrid = grid; this._spatialDirty = false; } /** Пометить spatial-индекс как устаревший. Должно вызываться при * add/remove/move инстанса в primitiveManager / modelManager. */ setSpatialDirty() { this._spatialDirty = true; } /** AABB-vs-AABB пересечение игрока с моделью. */ _aabbIntersectsModel(cx, cy, cz, hw, hh, hd, data) { // Берём кешированный локальный AABB модели; если ещё не посчитан — fallback 1×1×1 let local = data.localAABB; if (!local && this.modelManager) { this.modelManager._computeLocalAABB?.(data); local = data.localAABB; } if (!local) { local = { minX: -0.5, maxX: 0.5, minY: 0, maxY: 1, minZ: -0.5, maxZ: 0.5 }; } // Учёт rotationY: расширяем localAABB до axis-aligned обёртки повёрнутого bbox. // Это упрощённый подход (немного «толще» чем реальный OBB), но избавляет от // визуального «съезда» коллайдера у домов с rotationY != 0. let lminX = local.minX, lmaxX = local.maxX; let lminZ = local.minZ, lmaxZ = local.maxZ; const rotY = data.rotationY || 0; if (rotY !== 0) { const c = Math.cos(rotY), s = Math.sin(rotY); // Поворачиваем 4 угла local-bbox в плоскости XZ и берём новый AABB const corners = [ { x: local.minX, z: local.minZ }, { x: local.maxX, z: local.minZ }, { x: local.maxX, z: local.maxZ }, { x: local.minX, z: local.maxZ }, ]; let nminX = Infinity, nmaxX = -Infinity, nminZ = Infinity, nmaxZ = -Infinity; for (const cr of corners) { const rx = cr.x * c - cr.z * s; const rz = cr.x * s + cr.z * c; if (rx < nminX) nminX = rx; if (rx > nmaxX) nmaxX = rx; if (rz < nminZ) nminZ = rz; if (rz > nmaxZ) nmaxZ = rz; } lminX = nminX; lmaxX = nmaxX; lminZ = nminZ; lmaxZ = nmaxZ; } const minX = data.x + lminX, maxX = data.x + lmaxX; const minY = data.y + local.minY, maxY = data.y + local.maxY; const minZ = data.z + lminZ, maxZ = data.z + lmaxZ; return ( cx + hw > minX && cx - hw < maxX && cy + hh > minY && cy - hh < maxY && cz + hd > minZ && cz - hd < maxZ ); } /** * Пересечение AABB игрока с примитивом. * Если примитив повёрнут — используем точную OBB-проверку через SAT. * Иначе — быстрая AABB-vs-AABB. */ _aabbIntersectsPrimitive(cx, cy, cz, hw, hh, hd, p) { const phw = p.sx / 2, phh = p.sy / 2, phd = p.sz / 2; const rx = p.mesh?.rotation?.x || 0; const ry = p.mesh?.rotation?.y || 0; const rz = p.mesh?.rotation?.z || 0; // Без поворота — быстрая axis-aligned проверка if (rx === 0 && ry === 0 && rz === 0) { return ( cx + hw > p.x - phw && cx - hw < p.x + phw && cy + hh > p.y - phh && cy - hh < p.y + phh && cz + hd > p.z - phd && cz - hd < p.z + phd ); } // С поворотом — SAT (точное пересечение AABB×OBB) const axes = buildOBBAxes(rx, ry, rz); return aabbIntersectsOBB( cx, cy, cz, hw, hh, hd, p.x, p.y, p.z, phw, phh, phd, axes ); } /** * Найти все триггеры и чекпоинты которых сейчас касается AABB игрока. * Возвращает массив data-объектов. Использует тот же spatial-grid что * и _collidesAt — без него O(N) каждый кадр. */ getOverlappingPrimitives(cx, cy, cz, hw, hh, hd) { const out = []; if (!this.primitiveManager) return out; const candidates = this._getSpatialCandidates(cx, cz, hw, hd); if (!candidates) return out; for (const item of candidates) { if (item.kind !== 'primitive') continue; const data = item.data; if (data.type !== 'trigger' && data.type !== 'checkpoint') continue; if (this._aabbIntersectsPrimitive(cx, cy, cz, hw, hh, hd, data)) { out.push(data); } } return out; } }