studio/src/editor/engine/PhysicsAABB.js
МИН 31adbf151b Initial public release: Студия Рублокса v1.0
Open-source веб-студия для создания игр Рублокса, двойная лицензия
AGPL-3.0 + Коммерческая.

Главное:
- Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16
- Самодостаточный движок ~28к строк (66 файлов): BlockManager,
  TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController,
  ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов
- Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco)
- Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn)
- Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt)
- 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.)
- Конфигурируемый бэкенд через VITE_API_BASE — работает со staging
  (dev-api.rublox.pro) без настройки
- Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка
- Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING,
  SECURITY, CHANGELOG
- ESLint + Prettier + EditorConfig
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Перед публикацией:
- Все импорты из minecraftia заменены на локальные
- Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env
- Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо)
- AdminKubikonModeration не публикуется (модерация — в team.rublox.pro)
- 93 МБ ассетов public/kubikon-assets вынесены в .gitignore
  (раздаются через release artifact)
2026-05-27 23:41:10 +03:00

1196 lines
64 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.

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