import * as B from "@babylonjs/core"; import type { Entity, Project, Vec3 } from "./schema.ts"; import { tileRectangles } from "./two-d.ts"; let modulePromise: Promise | undefined; export const loadRapier2D = () => (modulePromise ??= import("@dimforge/rapier2d-compat").then(async (r) => { await r.init(); return r; })); interface BodyEntry { node: Entity; root: B.TransformNode; body: any; colliders: any[]; controller?: any; velocity: { x: number; y: number }; grounded: boolean; contacts: any[]; coyote: number; halfHeight: number; offsetY: number; previousY: number; } export class Physics2D { world: any; queue: any; entries = new Map(); owners = new Map(); events: any[] = []; joints = new Map(); private controllerPairs = new Map(); private input: any = {}; private jump = false; private lastJump = false; constructor( private R: any, gravity: number[] = [0, -9.81], ) { this.world = new R.World({ x: gravity[0], y: gravity[1] }); this.world.timestep = 1 / 60; this.queue = new R.EventQueue(true); } static async create(project: Project) { return new Physics2D( await loadRapier2D(), project.settings.physics2d?.gravity, ); } add(node: Entity, root: B.TransformNode) { const c = node.components.collider2d, tile = node.components.tilemap; if (!c && !tile?.collisions) return; const R = this.R, rb = node.components.rigidbody2d || { type: "fixed" }; root.computeWorldMatrix(true); const pos = root.getAbsolutePosition(), scale = root.absoluteScaling, rotation = root.absoluteRotationQuaternion.toEulerAngles().z; const desc = ( rb.type === "dynamic" ? R.RigidBodyDesc.dynamic() : rb.type === "kinematic" ? R.RigidBodyDesc.kinematicPositionBased() : R.RigidBodyDesc.fixed() ) .setTranslation(pos.x, pos.y) .setRotation(rotation) .setGravityScale(rb.gravityScale ?? 1) .setLinearDamping(rb.linearDamping ?? 0) .setAngularDamping(rb.angularDamping ?? 0) .setCcdEnabled(rb.ccd !== false); if (rb.lockRotation) desc.lockRotations(); const body = this.world.createRigidBody(desc), colliders: any[] = []; let halfHeight = 0.5, offsetY = (c?.offset?.[1] || 0) * scale.y; const add = (shape: any, offset: number[], sensor = false) => { shape .setTranslation(offset[0], offset[1]) .setMass(rb.mass ?? 1) .setFriction(rb.friction ?? 0.5) .setRestitution(rb.restitution ?? 0) .setSensor(sensor) .setCollisionGroups( (((c?.membership ?? 1) << 16) | (c?.mask ?? 65535)) >>> 0, ) .setActiveEvents(R.ActiveEvents.COLLISION_EVENTS) .setActiveCollisionTypes(R.ActiveCollisionTypes.ALL); if (c?.oneWay) shape.setActiveHooks(R.ActiveHooks.FILTER_CONTACT_PAIRS); const col = this.world.createCollider(shape, body); colliders.push(col); this.owners.set(col.handle, node.id); }; if (tile?.collisions) { for (const rect of tileRectangles(tile.cells)) add( R.ColliderDesc.cuboid( (rect.width * tile.tileSize[0] * Math.abs(scale.x)) / 2, (rect.height * tile.tileSize[1] * Math.abs(scale.y)) / 2, ), [ (rect.x + rect.width / 2) * tile.tileSize[0] * scale.x, (rect.y + rect.height / 2) * tile.tileSize[1] * scale.y, ], ); } else { let shape; const size = c.size || [1, 1]; halfHeight = (size[1] * Math.abs(scale.y)) / 2; if (c.shape === "circle") { halfHeight = (c.radius || 0.5) * Math.max(Math.abs(scale.x), Math.abs(scale.y)); shape = R.ColliderDesc.ball(halfHeight); } else if (c.shape === "capsule") { const radius = (c.radius || 0.3) * Math.abs(scale.x); halfHeight = Math.max( radius, ((c.height || 1.8) * Math.abs(scale.y)) / 2, ); shape = R.ColliderDesc.capsule(halfHeight - radius, radius); } else if (c.shape === "polygon") { const vertices = new Float32Array( c.points.flatMap((p: number[]) => [p[0] * scale.x, p[1] * scale.y]), ); shape = R.ColliderDesc.convexHull(vertices); halfHeight = Math.max( ...c.points.map((p: number[]) => Math.abs(p[1] * scale.y)), ); if (!shape) throw Error("Не удалось построить Collider2D"); } else shape = R.ColliderDesc.cuboid( (size[0] * Math.abs(scale.x)) / 2, halfHeight, ); add(shape, [(c.offset?.[0] || 0) * scale.x, offsetY], !!c.sensor); } const e: BodyEntry = { node, root, body, colliders, velocity: { x: 0, y: 0 }, grounded: false, contacts: [], coyote: 0, halfHeight, offsetY, previousY: pos.y, }; if (rb.type === "kinematic" && colliders.length === 1 && !c?.sensor) { e.controller = this.world.createCharacterController(0.015); e.controller.setUp({ x: 0, y: 1 }); const step = node.components.character2d?.autostep ?? 0; if (step > 0) e.controller.enableAutostep(step, 0.15, true); e.controller.disableSnapToGround(); e.controller.setApplyImpulsesToDynamicBodies(true); } this.entries.set(node.id, e); this.setEnabled(node.id); } connect() { for (const [id, e] of this.entries) { const j = e.node.components.joint2d, target = j && this.entries.get(j.targetId); if (!j || !target || this.joints.has(id)) continue; const a = { x: j.anchor[0], y: j.anchor[1] }, b = { x: j.targetAnchor[0], y: j.targetAnchor[1] }, J = this.R.JointData; let data; if (j.type === "revolute") data = J.revolute(a, b); else if (j.type === "rope") data = J.rope(j.length || 1, a, b); else if (j.type === "spring") data = J.spring(j.length || 1, j.stiffness ?? 50, j.damping ?? 5, a, b); else data = J.fixed(a, 0, b, 0); const joint = this.world.createImpulseJoint( data, e.body, target.body, true, ); joint.setContactsEnabled(false); this.joints.set(id, joint); } } setInput(input: any) { this.input = input; const jump = !!(input.jump || input.jumpPressed); if (jump && !this.lastJump) this.jump = true; this.lastJump = jump; } setEnabled(id: string) { const e = this.entries.get(id); if (!e) return; const enabled = e.root.isEnabled() && e.node.components.collider2d?.enabled !== false; e.body.setEnabled(enabled); for (const c of e.colliders) c.setEnabled(enabled); } velocity(id: string, value: any) { const e = this.entries.get(id); if (!e) return; if (e.body.isDynamic()) e.velocity = { ...e.body.linvel() }; for (const k of ["x", "y"] as const) if (value[k] !== undefined) { if (!Number.isFinite(value[k])) throw Error("Неверная скорость 2D"); e.velocity[k] = Math.max(-200, Math.min(200, value[k])); } if (value.gravityScale !== undefined) { if (!Number.isFinite(value.gravityScale)) throw Error("Неверная гравитация"); e.body.setGravityScale(value.gravityScale, true); } if (e.body.isDynamic()) e.body.setLinvel(e.velocity, true); } impulse(id: string, value: number[]) { if ( !Array.isArray(value) || value.length !== 2 || !value.every(Number.isFinite) ) throw Error("Импульс 2D: [x,y]"); this.entries.get(id)?.body.applyImpulse({ x: value[0], y: value[1] }, true); } teleport(id: string, position: number[]) { const e = this.entries.get(id); if (!e) return; if ( !Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite) ) throw Error("Позиция 2D: [x,y,z]"); e.node.transform.position = [...position] as Vec3; e.root.position.copyFromFloats(...e.node.transform.position); e.root.computeWorldMatrix(true); this.patch(id, true); e.body.setLinvel({ x: 0, y: 0 }, true); e.velocity = { x: 0, y: 0 }; e.grounded = false; e.contacts = []; } patch(id: string, position = false, rotation = false) { const e = this.entries.get(id); if (!e) return; e.root.computeWorldMatrix(true); if (position) { const p = e.root.getAbsolutePosition(); e.body.setTranslation({ x: p.x, y: p.y }, true); if (e.body.isKinematic()) e.body.setNextKinematicTranslation({ x: p.x, y: p.y }); e.previousY = p.y; } if (rotation) { const angle = e.root.absoluteRotationQuaternion.toEulerAngles().z; e.body.setRotation(angle, true); if (e.body.isKinematic()) e.body.setNextKinematicRotation(angle); } this.setEnabled(id); } private accepts(platform: BodyEntry, other: BodyEntry) { const top = platform.previousY + platform.offsetY + platform.halfHeight; const vel = other.velocity; return ( vel.y <= 0.05 && other.previousY + other.offsetY - other.halfHeight >= top - 0.08 ); } step(dt: number, moves: Map, divisor = 1) { for (const e of this.entries.values()) { e.previousY = e.body.translation().y; if (e.body.isDynamic()) e.velocity = { ...e.body.linvel() }; } for (const [id, e] of this.entries) { e.contacts = []; if (!e.body.isEnabled()) continue; const c = e.node.components.character2d, move = moves.get(id) || [0, 0, 0]; if (e.controller) { if (c) { if (c.controls !== false) { e.velocity.x = (this.input.x || 0) * (c.speed ?? 5); if (c.mode === "topDown") { e.velocity.y = (this.input.y ?? this.input.z ?? 0) * (c.speed ?? 5); const length = Math.hypot(e.velocity.x, e.velocity.y); if (length > (c.speed ?? 5)) { e.velocity.x *= (c.speed ?? 5) / length; e.velocity.y *= (c.speed ?? 5) / length; } } } if (c.mode !== "topDown") { e.coyote = e.grounded ? 0.1 : Math.max(0, e.coyote - dt); if (this.jump && c.controls !== false && e.coyote > 0) { e.velocity.y = c.jumpSpeed ?? 8; e.coyote = 0; e.grounded = false; } e.velocity.y = Math.max(-50, e.velocity.y - (c.gravity ?? 20) * dt); } } const desired = { x: e.velocity.x * dt + move[0] / divisor, y: e.velocity.y * dt + move[1] / divisor, }; e.controller.computeColliderMovement( e.colliders[0], desired, this.R.QueryFilterFlags.EXCLUDE_SENSORS, e.colliders[0].collisionGroups(), (col: any) => { const owner = this.entries.get(this.owners.get(col.handle)!); return ( !owner?.node.components.collider2d?.oneWay || this.accepts(owner, e) ); }, ); const delta = e.controller.computedMovement(), p = e.body.translation(); e.body.setNextKinematicTranslation({ x: p.x + delta.x, y: p.y + delta.y, }); e.grounded = e.controller.computedGrounded(); for (let i = 0; i < e.controller.numComputedCollisions(); i++) { const hit = e.controller.computedCollision(i); if (hit?.collider) e.contacts.push({ entityId: this.owners.get(hit.collider.handle), normal: [hit.normal1.x, hit.normal1.y, 0], }); } if (e.grounded && e.velocity.y < 0) e.velocity.y = 0; if (e.contacts.some((c) => c.normal[1] < -0.5) && e.velocity.y > 0) e.velocity.y = 0; } else if (e.body.isKinematic()) { const p = e.body.translation(); e.body.setNextKinematicTranslation({ x: p.x + e.velocity.x * dt + move[0] / divisor, y: p.y + e.velocity.y * dt + move[1] / divisor, }); } else if (move[0] || move[1]) { const p = e.body.translation(); e.body.setTranslation( { x: p.x + move[0] / divisor, y: p.y + move[1] / divisor }, true, ); } } this.jump = false; this.world.step(this.queue, { filterContactPair: (a: number, b: number) => { const ea = this.entries.get(this.owners.get(a)!), eb = this.entries.get(this.owners.get(b)!); if ( ea && eb && ((ea.node.components.collider2d?.oneWay && !this.accepts(ea, eb)) || (eb.node.components.collider2d?.oneWay && !this.accepts(eb, ea))) ) return null; return this.R.SolverFlags.COMPUTE_IMPULSE; }, filterIntersectionPair: () => true, }); this.queue.drainCollisionEvents( (a: number, b: number, started: boolean) => { const id = this.owners.get(a), otherId = this.owners.get(b); if (!id || !otherId) return; const sensor = this.world.getCollider(a)?.isSensor() || this.world.getCollider(b)?.isSensor(); if ( !sensor && (this.entries.get(id)?.controller || this.entries.get(otherId)?.controller) ) return; for (const [entityId, other] of [ [id, otherId], [otherId, id], ]) this.events.push({ entityId, otherId: other, started, sensor: !!sensor, }); }, ); // Character-controller contacts stop at a small gap, so no solver collision is generated. const pairs = new Map(); for (const [id, e] of this.entries) for (const hit of e.contacts) if (hit.entityId) { const pair = [id, hit.entityId].sort() as [string, string]; pairs.set(pair.join("|"), pair); } for (const [current, previous, started] of [ [pairs, this.controllerPairs, true], [this.controllerPairs, pairs, false], ] as const) for (const [key, [a, b]] of current) if (!previous.has(key)) { this.events.push( { entityId: a, otherId: b, started, sensor: false }, { entityId: b, otherId: a, started, sensor: false }, ); } this.controllerPairs = pairs; if (this.events.length > 2000) this.events.splice(0, this.events.length - 2000); // Parents first: child physics poses are converted through their current world matrix. const depth = (e: BodyEntry) => { let n = e.root.parent, d = 0; while (n) { d++; n = n.parent; } return d; }; for (const e of [...this.entries.values()].sort( (a, b) => depth(a) - depth(b), )) { if (!e.body.isEnabled()) continue; const pos = e.body.translation(); e.root.computeWorldMatrix(true); const z = e.root.getAbsolutePosition().z; let local = new B.Vector3(pos.x, pos.y, z); if (e.root.parent) local = B.Vector3.TransformCoordinates( local, B.Matrix.Invert(e.root.parent.getWorldMatrix()), ); e.root.position.copyFrom(local); e.node.transform.position = local.asArray() as Vec3; if (e.body.isDynamic() || e.body.isKinematic()) { let q = B.Quaternion.RotationAxis(B.Axis.Z, e.body.rotation()); if (e.root.parent instanceof B.TransformNode) q = e.root.parent.absoluteRotationQuaternion.conjugate().multiply(q); e.root.rotationQuaternion = q; e.node.transform.rotation = q.toEulerAngles().asArray() as Vec3; } e.root.computeWorldMatrix(true); } } snapshot() { return Object.fromEntries( [...this.entries].map(([id, e]) => [ id, { dimension: 2, grounded: e.grounded, velocity: { ...(e.body.isDynamic() ? e.body.linvel() : e.velocity), z: 0, }, contacts: e.contacts, enabled: e.body.isEnabled(), }, ]), ); } drainEvents() { const result = this.events; this.events = []; return result; } dispose() { this.controllerPairs.clear(); this.entries.clear(); this.owners.clear(); this.joints.clear(); this.events = []; this.queue.free(); this.world.free(); } }