import RAPIER from '@dimforge/rapier3d-compat'; import type { WorldModel } from '../sim/world'; import { CAR, WHEELS } from '../carSpec'; export interface PhysicsWorld { rapier: RAPIER.World; events: RAPIER.EventQueue; chassis: RAPIER.RigidBody; vehicle: RAPIER.DynamicRayCastVehicleController; /** One body per obstacle, in the same order as model.obstacles. */ obstacleBodies: RAPIER.RigidBody[]; /** Contact-force magnitude accumulated on the chassis since the last read. */ drainImpactForce(): number; step(dt: number): void; respawn(): void; /** Static box, added and removed at runtime as road heat rises and falls. */ addStaticBox(box: StaticBox): RAPIER.RigidBody; removeBody(body: RAPIER.RigidBody): void; /** What the wheels are doing, for anything that needs to react to grip. */ telemetry(): Telemetry; /** A body the sim moves by hand, which still collides with the player. */ addKinematicBox(box: KinematicBox): RAPIER.RigidBody; } export interface KinematicBox { x: number; y: number; z: number; yaw: number; halfExtents: { x: number; y: number; z: number }; } export interface Telemetry { /** Sum of lateral impulses across the wheels, scaled to roughly 0..1+. */ sideSlip: number; wheelsOnGround: number; } export interface StaticBox { x: number; z: number; yaw: number; width: number; height: number; depth: number; } /** Contacts weaker than this are just kerb-scrubbing, not damage. */ const IMPACT_THRESHOLD = 4000; export async function createPhysics(model: WorldModel): Promise { await RAPIER.init(); const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 }); const events = new RAPIER.EventQueue(true); // --- Ground: a flat plate for now. Terrain and roads land here later. --- const groundBody = world.createRigidBody( RAPIER.RigidBodyDesc.fixed().setTranslation(0, -0.5, 0), ); world.createCollider( RAPIER.ColliderDesc.cuboid(model.extent + 60, 0.5, model.extent + 60).setFriction(1.1), groundBody, ); // --- Obstacles --- const obstacleBodies = model.obstacles.map((o) => { const half = { x: o.width / 2, y: o.height / 2, z: o.depth / 2 }; const desc = o.kind === 'crate' ? RAPIER.RigidBodyDesc.dynamic() : RAPIER.RigidBodyDesc.fixed(); const body = world.createRigidBody( desc .setTranslation(o.x, half.y, o.z) .setRotation({ x: 0, y: Math.sin(o.yaw / 2), z: 0, w: Math.cos(o.yaw / 2) }), ); world.createCollider( RAPIER.ColliderDesc.cuboid(half.x, half.y, half.z) .setDensity(o.kind === 'crate' ? 60 : 0) .setFriction(0.8), body, ); return body; }); // --- Car chassis --- const chassis = world.createRigidBody( RAPIER.RigidBodyDesc.dynamic() .setTranslation(model.spawn.x, CAR.spawn.y, model.spawn.z) .setLinearDamping(0.1) .setAngularDamping(0.4) // The mass comes from here, not from collider density, so the centre of mass // can sit below the box centre — a high CoM makes the raycast vehicle flip. .setAdditionalMassProperties( CAR.mass, { x: 0, y: -0.35, z: 0 }, { x: 1369, y: 1621, z: 342 }, { x: 0, y: 0, z: 0, w: 1 }, ), ); const chassisCollider = world.createCollider( RAPIER.ColliderDesc.cuboid(CAR.halfWidth, CAR.halfHeight, CAR.halfLength) .setDensity(0) .setFriction(0.4) .setActiveEvents(RAPIER.ActiveEvents.CONTACT_FORCE_EVENTS) .setContactForceEventThreshold(IMPACT_THRESHOLD), chassis, ); const vehicle = world.createVehicleController(chassis); vehicle.indexUpAxis = 1; // Typings name this setter oddly; it is the forward-axis setter. 2 = local +Z. vehicle.setIndexForwardAxis = 2; for (const w of WHEELS) { vehicle.addWheel( { x: w.x, y: CAR.wheel.offsetY, z: w.z }, { x: 0, y: -1, z: 0 }, { x: -1, y: 0, z: 0 }, CAR.wheel.suspensionRestLength, CAR.wheel.radius, ); } for (let i = 0; i < WHEELS.length; i++) { vehicle.setWheelSuspensionStiffness(i, 24); vehicle.setWheelSuspensionCompression(i, 2.0); vehicle.setWheelSuspensionRelaxation(i, 3.0); vehicle.setWheelMaxSuspensionTravel(i, 0.25); vehicle.setWheelMaxSuspensionForce(i, 40000); vehicle.setWheelSideFrictionStiffness(i, 1); vehicle.setWheelFrictionSlip(i, 4); } let pendingImpact = 0; const chassisHandle = chassisCollider.handle; return { rapier: world, events, chassis, vehicle, obstacleBodies, step(dt: number) { world.timestep = dt; vehicle.updateVehicle(dt); world.step(events); events.drainContactForceEvents((e) => { if (e.collider1() === chassisHandle || e.collider2() === chassisHandle) { pendingImpact += e.totalForceMagnitude(); } }); }, drainImpactForce() { const v = pendingImpact; pendingImpact = 0; return v; }, addKinematicBox(box) { // Kinematic rather than dynamic: the unit sim owns where these are, but // they still shove the player's car when they meet it. const body = world.createRigidBody( RAPIER.RigidBodyDesc.kinematicPositionBased().setTranslation(box.x, box.y, box.z), ); world.createCollider( RAPIER.ColliderDesc.cuboid(box.halfExtents.x, box.halfExtents.y, box.halfExtents.z) .setFriction(0.6) .setActiveEvents(RAPIER.ActiveEvents.CONTACT_FORCE_EVENTS) .setContactForceEventThreshold(IMPACT_THRESHOLD), body, ); return body; }, telemetry() { let sideSlip = 0; let wheelsOnGround = 0; for (let i = 0; i < WHEELS.length; i++) { if (!vehicle.wheelIsInContact(i)) continue; wheelsOnGround++; // The lateral impulse the tyre had to generate to hold the line. Scaled // against the car's weight, so it reads as "how hard is it working". sideSlip += Math.abs(vehicle.wheelSideImpulse(i) ?? 0); } return { sideSlip: sideSlip / (CAR.mass * 0.09), wheelsOnGround }; }, addStaticBox(box) { const body = world.createRigidBody( RAPIER.RigidBodyDesc.fixed() .setTranslation(box.x, box.height / 2, box.z) .setRotation({ x: 0, y: Math.sin(box.yaw / 2), z: 0, w: Math.cos(box.yaw / 2) }), ); world.createCollider( RAPIER.ColliderDesc.cuboid(box.width / 2, box.height / 2, box.depth / 2).setFriction(0.8), body, ); return body; }, removeBody(body) { world.removeRigidBody(body); }, respawn() { chassis.setTranslation({ x: model.spawn.x, y: CAR.spawn.y, z: model.spawn.z }, true); chassis.setRotation({ x: 0, y: 0, z: 0, w: 1 }, true); chassis.setLinvel({ x: 0, y: 0, z: 0 }, true); chassis.setAngvel({ x: 0, y: 0, z: 0 }, true); }, }; }