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