/** Velocity-based kinematic motion. Rapier remains the collision authority. */ export class CharacterMotor { velocity = { x: 0, y: 0, z: 0 }; actualVelocity = { x: 0, y: 0, z: 0 }; grounded = false; gravityScale = 1; contacts: { handle: number; normal: number[] }[] = []; constructor(public body: any, public collider: any, public controller: any, public config: any, private rapier: any) {} set(value: any) { for (const axis of ["x", "y", "z"] as const) if (value[axis] !== undefined) { if (!Number.isFinite(value[axis])) throw Error("Invalid character velocity"); this.velocity[axis] = Math.max(-100, Math.min(100, value[axis])); } if (value.gravityScale !== undefined) { if (!Number.isFinite(value.gravityScale)) throw Error("Invalid gravity scale"); this.gravityScale = Math.max(0, Math.min(5, value.gravityScale)); } } teleport(position: number[]) { if (position.length !== 3 || !position.every(Number.isFinite)) throw Error("Invalid teleport"); const p = { x: position[0], y: position[1], z: position[2] }; this.body.setTranslation(p, true); this.body.setNextKinematicTranslation(p); this.velocity = { x: 0, y: 0, z: 0 }; this.actualVelocity = { x: 0, y: 0, z: 0 }; this.gravityScale = 1; this.grounded = false; this.contacts = []; } step(dt: number, extra = [0, 0, 0]) { this.velocity.y = Math.max(-45, this.velocity.y - (this.config.gravity ?? 24) * this.gravityScale * dt); // Fixed-step gravity already maintains ground contact. Rapier 0.20 snap-down // plus small vertical gravity steps can accumulate penetration on flat floors. this.controller.disableSnapToGround(); this.controller.computeColliderMovement(this.collider, { x: this.velocity.x * dt + extra[0], y: this.velocity.y * dt + extra[1], z: this.velocity.z * dt + extra[2], }, this.rapier.QueryFilterFlags.EXCLUDE_SENSORS); const movement = this.controller.computedMovement(), p = this.body.translation(); this.actualVelocity = { x: movement.x / dt, y: movement.y / dt, z: movement.z / dt }; this.body.setNextKinematicTranslation({ x: p.x + movement.x, y: p.y + movement.y, z: p.z + movement.z }); this.grounded = this.controller.computedGrounded(); this.contacts = []; for (let i = 0; i < this.controller.numComputedCollisions(); i++) { const hit = this.controller.computedCollision(i); if (hit?.collider) this.contacts.push({ handle: hit.collider.handle, normal: [hit.normal1.x, hit.normal1.y, hit.normal1.z] }); } if (this.grounded && this.velocity.y < 0) this.velocity.y = 0; if (this.velocity.y > 0 && this.contacts.some(c => c.normal[1] < -0.6)) this.velocity.y = 0; } }