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