/** * Gives the inhabited world a physical presence. Integration layer: Rapier on * one side, the pure unit sim on the other. * * Units are simulated as points that ignore each other, which is fine for * traffic going about its business and useless the moment the player wants to * interact with any of it. This is what makes them things you can hit. * * Two deliberately different treatments: * * - **Vehicles get colliders.** Kinematic bodies, driven from the sim, so * ramming a patrol is a real collision that shoves your car about. * - **People do not.** A capsule collider would mean getting hung up on * pedestrians, or launching them like skittles. Instead they are checked for * proximity and simply go down. You drive *through* a person, not into them. */ import type RAPIER from '@dimforge/rapier3d-compat'; import type { PhysicsWorld } from './physics/physics'; import type { Unit, UnitState } from './sim/units'; /** Units further out than this get no body; the physics world stays small. */ const PHYSICAL_RANGE = 160; /** Half-extents of a vehicle body. */ const CAR_HALF = { x: 0.9, y: 0.7, z: 2 }; /** How close the car has to pass to knock a person down. */ const RUN_OVER_RADIUS = 2.6; /** Below this you are nudging past someone, not running them over. */ const RUN_OVER_SPEED = 3.5; /** Closing speed at which a vehicle collision starts hurting the other car. */ const RAM_SPEED = 7; const RAM_RADIUS = 4.6; export interface ContactEvents { /** People knocked down by the player this step. */ ranOver: Unit[]; /** Vehicles the player rammed hard enough to damage. */ rammed: Unit[]; } export function createUnitBodies(physics: PhysicsWorld) { const bodies = new Map(); const drop = (id: number) => { const body = bodies.get(id); if (!body) return; physics.removeBody(body); bodies.delete(id); }; return { /** * Syncs bodies to the sim and reports what the player just hit. * Call once per fixed step, after the units have moved. */ sync( units: UnitState, player: { x: number; z: number }, playerSpeed: number, dt: number, ): ContactEvents { const events: ContactEvents = { ranOver: [], rammed: [] }; const alive = new Set(); const fast = Math.abs(playerSpeed); for (const unit of units.units) { const gap = Math.hypot(unit.x - player.x, unit.z - player.z); if (unit.kind === 'soldier') { // No collider — just whether the car went through them. if (gap < RUN_OVER_RADIUS && fast > RUN_OVER_SPEED && unit.hp > 0) { unit.hp = 0; events.ranOver.push(unit); } continue; } if (gap > PHYSICAL_RANGE) continue; alive.add(unit.id); let body = bodies.get(unit.id); if (!body) { body = physics.addKinematicBox({ x: unit.x, z: unit.z, y: CAR_HALF.y, yaw: unit.heading, halfExtents: CAR_HALF, }); bodies.set(unit.id, body); } // Kinematic: the sim decides where it is, and the physics engine works // out what that does to anything dynamic it meets — namely the player. body.setNextKinematicTranslation({ x: unit.x, y: CAR_HALF.y, z: unit.z }); body.setNextKinematicRotation({ x: 0, y: Math.sin(unit.heading / 2), z: 0, w: Math.cos(unit.heading / 2), }); // Rapier will resolve the shove; the damage is ours to decide. if (gap < RAM_RADIUS && fast > RAM_SPEED) { unit.hp -= fast * 4 * dt; if (!events.rammed.includes(unit)) events.rammed.push(unit); } } for (const id of [...bodies.keys()]) if (!alive.has(id)) drop(id); return events; }, get count() { return bodies.size; }, }; }