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)
1196 lines
64 KiB
JavaScript
1196 lines
64 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
}
|