studio/src/editor/engine/PhysicsWorld.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

295 lines
13 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.

/**
* PhysicsWorld — обёртка над Rapier3D-compat (wasm физ-движок).
*
* Phase 6.5: настоящая динамическая физика для объектов, которым скрипт
* установил `physics.bodyType = 'dynamic'`. Игрок и обычные блоки/модели
* остаются на самописной DynamicsManager — Rapier для них НЕ включается.
*
* Архитектура:
* 1. init() — асинхронная загрузка wasm (~400КБ). Возвращает promise.
* Пока wasm не загружен, addBody возвращает null (no-op).
* 2. step(dt) — продвинуть симуляцию на dt секунд. Зовётся каждый кадр
* из GameRuntime.tick() ПОСЛЕ обычной физики.
* 3. addBody(ref, opts) — зарегистрировать примитив как rigid body.
* opts: { bodyType, mass, friction, restitution, position, rotation, size, shape }
* 4. removeBody(ref) — удалить из физ-мира.
* 5. getBodyTransform(ref) — после step получить новое position/rotation
* для синхронизации с Babylon-mesh.
* 6. raycast(origin, dir, maxDist) — настоящий raycast по геометрии.
* 7. addJoint(refA, refB, opts) — hinge / spring / distance constraint.
*
* Безопасность:
* - При любой ошибке Rapier-инициализации движок переходит в "disabled" режим
* и все API становятся no-op. Игра не падает.
* - removeBody / removeJoint всегда безопасны (повторный вызов не падает).
* - dispose() обязателен при exitPlayMode, иначе wasm-память утечёт.
*/
import RAPIER from '@dimforge/rapier3d-compat';
// Глобальный singleton — Rapier wasm можно инициализировать только один раз
// за время жизни страницы. После dispose() оставляем wasm загруженным,
// просто пересоздаём World для нового Play.
let _wasmReady = false;
let _wasmInitPromise = null;
async function _ensureWasm() {
if (_wasmReady) return true;
if (_wasmInitPromise) return _wasmInitPromise;
_wasmInitPromise = (async () => {
try {
await RAPIER.init();
_wasmReady = true;
return true;
} catch (e) {
console.error('[PhysicsWorld] Rapier wasm init failed:', e);
_wasmReady = false;
return false;
}
})();
return _wasmInitPromise;
}
export class PhysicsWorld {
constructor() {
this.world = null; // RAPIER.World
this.bodies = new Map(); // ref → { body: RigidBody, collider: Collider, shape, lastPos, lastRot }
this.joints = new Map(); // jointId → ImpulseJoint
this._jointSeq = 0;
this._ready = false;
this._disposed = false;
}
async init(gravity = -22) {
const ok = await _ensureWasm();
if (!ok) return false;
if (this._disposed) return false;
// Гравитация по Y отрицательная (как в Babylon-мире).
this.world = new RAPIER.World({ x: 0.0, y: gravity, z: 0.0 });
this._ready = true;
return true;
}
isReady() { return this._ready && !this._disposed; }
/**
* Шаг симуляции. dt в секундах. Rapier работает в собственном FixedDt,
* но мы передаём фактический dt — он сам разобьёт на substep'ы при необходимости.
*/
step(dt) {
if (!this.isReady()) return;
// Rapier ожидает что world.timestep = 1/60. Меняем на текущий dt чтобы
// подстраивалось под FPS. Безопасно: clamp в [1/240, 1/30].
const ts = Math.max(1 / 240, Math.min(1 / 30, dt));
this.world.timestep = ts;
this.world.step();
}
/**
* Добавить rigid body для примитива.
* @param ref строковый ref (primitive:N).
* @param opts {
* bodyType: 'dynamic' | 'static' | 'kinematic',
* shape: 'box' | 'sphere' | 'cylinder' | 'capsule',
* size: {x, y, z} -- полу-размеры для box, или {radius} для sphere
* position: {x, y, z},
* rotation: {x, y, z, w} (кватернион) или {y: yaw_radians} как fallback,
* mass: число (default 1),
* friction: 0..1 (default 0.5),
* restitution: 0..1 (default 0.0) -- упругость
* }
* @returns true если добавили, false при ошибке.
*/
addBody(ref, opts) {
if (!this.isReady() || !ref || !opts) return false;
if (this.bodies.has(ref)) return true; // уже есть
const W = this.world;
const pos = opts.position || { x: 0, y: 0, z: 0 };
const rot = opts.rotation || { x: 0, y: 0, z: 0, w: 1 };
// Body descriptor по типу
let bodyDesc;
if (opts.bodyType === 'static') {
bodyDesc = RAPIER.RigidBodyDesc.fixed();
} else if (opts.bodyType === 'kinematic') {
bodyDesc = RAPIER.RigidBodyDesc.kinematicPositionBased();
} else {
bodyDesc = RAPIER.RigidBodyDesc.dynamic();
}
bodyDesc.setTranslation(pos.x, pos.y, pos.z);
if (typeof rot.w === 'number') {
bodyDesc.setRotation(rot);
} else if (typeof rot.y === 'number') {
// Yaw -> quaternion
const h = rot.y / 2;
bodyDesc.setRotation({ x: 0, y: Math.sin(h), z: 0, w: Math.cos(h) });
}
const body = W.createRigidBody(bodyDesc);
// Collider
const shape = opts.shape || 'box';
const size = opts.size || { x: 0.5, y: 0.5, z: 0.5 };
let colDesc;
if (shape === 'sphere') {
const r = (size.radius != null) ? size.radius : Math.max(size.x || 0.5, size.y || 0.5, size.z || 0.5);
colDesc = RAPIER.ColliderDesc.ball(r);
} else if (shape === 'cylinder') {
colDesc = RAPIER.ColliderDesc.cylinder(size.y || 0.5, size.x || 0.5);
} else if (shape === 'capsule') {
colDesc = RAPIER.ColliderDesc.capsule(size.y || 0.5, size.x || 0.5);
} else {
// box: полу-размеры
colDesc = RAPIER.ColliderDesc.cuboid(size.x || 0.5, size.y || 0.5, size.z || 0.5);
}
colDesc.setDensity(opts.mass != null ? opts.mass : 1);
colDesc.setFriction(opts.friction != null ? opts.friction : 0.5);
colDesc.setRestitution(opts.restitution != null ? opts.restitution : 0.0);
const collider = W.createCollider(colDesc, body);
this.bodies.set(ref, {
body, collider, shape, size,
// Кеш последней позиции/поворота для дельты в getBodyTransform
lastPos: { ...pos },
lastRot: typeof rot.w === 'number' ? { ...rot } : { x: 0, y: 0, z: 0, w: 1 },
});
return true;
}
/** Удалить тело из физ-мира. Безопасно вызывать повторно. */
removeBody(ref) {
const rec = this.bodies.get(ref);
if (!rec) return;
try { this.world.removeRigidBody(rec.body); } catch (_) {}
this.bodies.delete(ref);
}
/** Применить мгновенный импульс к dynamic body. */
applyImpulse(ref, ix, iy, iz) {
const rec = this.bodies.get(ref);
if (!rec) return;
try { rec.body.applyImpulse({ x: ix, y: iy, z: iz }, true); } catch (_) {}
}
/** Задать скорость dynamic body напрямую. */
setVelocity(ref, vx, vy, vz) {
const rec = this.bodies.get(ref);
if (!rec) return;
try { rec.body.setLinvel({ x: vx, y: vy, z: vz }, true); } catch (_) {}
}
/**
* Получить текущую позицию и поворот тела после шага физики.
* Возвращает { x, y, z, qx, qy, qz, qw } или null.
*/
getBodyTransform(ref) {
const rec = this.bodies.get(ref);
if (!rec) return null;
try {
const t = rec.body.translation();
const r = rec.body.rotation();
return { x: t.x, y: t.y, z: t.z, qx: r.x, qy: r.y, qz: r.z, qw: r.w };
} catch (_) { return null; }
}
/**
* Raycast по физ-миру.
* @returns { hit, point, ref, distance, normal } или null.
*/
raycast(origin, dir, maxDist = 100) {
if (!this.isReady()) return null;
try {
const ray = new RAPIER.Ray(
{ x: origin.x, y: origin.y, z: origin.z },
{ x: dir.x, y: dir.y, z: dir.z }
);
const hit = this.world.castRayAndGetNormal(ray, maxDist, true);
if (!hit) return null;
const t = hit.timeOfImpact ?? hit.toi;
const point = {
x: origin.x + dir.x * t,
y: origin.y + dir.y * t,
z: origin.z + dir.z * t,
};
// Найти ref по collider'у. Линейный поиск (O(N)) — для сотен тел ok.
let foundRef = null;
const colHandle = hit.collider?.handle;
for (const [r, rec] of this.bodies) {
if (rec.collider.handle === colHandle) { foundRef = r; break; }
}
return {
hit: true,
point,
ref: foundRef,
distance: t,
normal: hit.normal ? { x: hit.normal.x, y: hit.normal.y, z: hit.normal.z } : null,
};
} catch (e) {
return null;
}
}
/**
* Hinge-сустав: тело A может вращаться вокруг axis относительно тела B.
* @returns jointId (число) или null.
*/
addHinge(refA, refB, opts) {
if (!this.isReady()) return null;
const recA = this.bodies.get(refA);
const recB = this.bodies.get(refB);
if (!recA || !recB) return null;
try {
const anchorA = opts.anchorA || { x: 0, y: 0, z: 0 };
const anchorB = opts.anchorB || { x: 0, y: 0, z: 0 };
const axis = opts.axis || { x: 0, y: 1, z: 0 };
const params = RAPIER.JointData.revolute(anchorA, anchorB, axis);
const joint = this.world.createImpulseJoint(params, recA.body, recB.body, true);
const id = ++this._jointSeq;
this.joints.set(id, joint);
return id;
} catch (_) { return null; }
}
/** Distance-constraint (верёвка/жёсткая дистанция). */
addDistance(refA, refB, opts) {
if (!this.isReady()) return null;
const recA = this.bodies.get(refA);
const recB = this.bodies.get(refB);
if (!recA || !recB) return null;
try {
const anchorA = opts.anchorA || { x: 0, y: 0, z: 0 };
const anchorB = opts.anchorB || { x: 0, y: 0, z: 0 };
// У Rapier 0.12 distance-joint называется RopeJointData в API, но
// мы используем generic Joint через spherical c distance limit.
const params = RAPIER.JointData.spherical(anchorA, anchorB);
const joint = this.world.createImpulseJoint(params, recA.body, recB.body, true);
const id = ++this._jointSeq;
this.joints.set(id, joint);
return id;
} catch (_) { return null; }
}
/** Удалить сустав. */
removeJoint(id) {
const joint = this.joints.get(id);
if (!joint) return;
try { this.world.removeImpulseJoint(joint, true); } catch (_) {}
this.joints.delete(id);
}
/**
* Полная очистка физ-мира. Вызывать при exitPlayMode.
*/
dispose() {
if (this._disposed) return;
this._disposed = true;
this._ready = false;
for (const [id] of this.joints) {
try { this.world?.removeImpulseJoint(this.joints.get(id), true); } catch (_) {}
}
this.joints.clear();
for (const [ref] of this.bodies) {
try { this.world?.removeRigidBody(this.bodies.get(ref).body); } catch (_) {}
}
this.bodies.clear();
try { this.world?.free(); } catch (_) {}
this.world = null;
}
}