diff --git a/README.md b/README.md index 5be5c00..b51758c 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,13 @@ number. The minimap shows who held each patch of ground *when you last stood in it* — so after a while away, your map is simply wrong, and the only way to find out is to go back and look. +Your own ground is a pocket, not the map: roughly 15% liberated, 25% contested, +27% occupied, 33% frontier. The starting base sits at the friendly *edge* rather +than in the middle, because starting central put half the world harmlessly +behind your own line where nothing watches and nothing happens. Those splits are +measured, not guessed — the bands are strips across a square with the axis +running diagonally, so area is nowhere near linear in depth. + **You are never told which band you are in by the world itself.** Crossing a border eases the fog, the light and the palette: your own ground is open and green, contested goes flat and hazy, occupied turns dusty and closes in, the diff --git a/src/main.ts b/src/main.ts index e9df8ae..312037c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -29,6 +29,7 @@ import { createHeatProps } from './heatProps'; import { createUnits, dispatchTo, stepUnits } from './sim/units'; import { createCombat, dangerNear, stepCombat } from './sim/combat'; import { createUnitView } from './render/units'; +import { createUnitBodies } from './unitBodies'; import { createPersistence } from './persistence'; import { apply, type Snapshot } from './sim/save'; import { makeRng, seedFromString } from './core/rng'; @@ -100,6 +101,7 @@ async function boot() { const units = createUnits(); const combat = createCombat(); const unitView = createUnitView(view.scene); + const unitBodies = createUnitBodies(physics); // Bullets stop at buildings, so combat needs a fast "is this inside a wall" // lookup. A grid built once at boot beats scanning a thousand obstacles. @@ -163,6 +165,8 @@ async function boot() { let driftAverage = 0; /** Seconds R has been held, toward a full reset. */ let resetHeld = 0; + /** People you have killed who were not part of anyone's war. */ + let civilianDeaths = 0; const say = (text: string, seconds = 4) => { notice = text; @@ -510,6 +514,24 @@ async function boot() { towerBodies.delete(id); } + // --- Hitting things --- + // Vehicles are solid; people are not, so you drive through them rather + // than getting hung up on them. + const contact = unitBodies.sync(units, { x: at.x, z: at.z }, speed, dt); + for (const victim of contact.ranOver) { + audio.impact(26000); + civilianDeaths += victim.faction === 'civilian' ? 1 : 0; + say( + victim.faction === 'civilian' + ? 'You just put someone under the wheels.' + : 'One of theirs, under the wheels.', + 4, + ); + } + if (contact.rammed.length > 0 && elapsed > noticeUntil) { + say('Metal on metal. Somebody noticed that.', 3); + } + // --- Shooting --- // The enemy shoots at the player only once a road has been noticed enough // to be checkpointed, and only past the line. Undercover means undercover. @@ -693,6 +715,8 @@ async function boot() { condition, front: front.boundaries, opportunity: opportunities.current, + civilianDeaths, + unitBodies: unitBodies.count, units: units.units.length, roles: units.units.reduce>((counts, u) => { counts[u.role] = (counts[u.role] ?? 0) + 1; diff --git a/src/physics/drive.ts b/src/physics/drive.ts index c126454..aec790e 100644 --- a/src/physics/drive.ts +++ b/src/physics/drive.ts @@ -4,13 +4,18 @@ import type { PhysicsWorld } from './physics'; import { WHEELS } from '../carSpec'; /** How fast the steering rack follows the key, radians per second. */ -const STEER_RATE = 2.6; -const STEER_RETURN_RATE = 4.5; +const STEER_RATE = 4.6; +const STEER_RETURN_RATE = 6; /** - * Steering authority falls off with speed, or the car is undriveable at pace. - * Lower = more falloff. At 12, full lock is roughly halved by 45 km/h. + * Speed at which steering authority is halved, metres per second. + * + * The falloff is quadratic rather than linear, and that matters. Linear falloff + * starts eating lock immediately — at 20 km/h the car had already lost a third + * of its steering, which is exactly the speed you take a 90-degree junction at, + * so the map's right-angle corners were nearly impossible to hit. A quadratic + * curve leaves low speeds almost untouched and still calms the car down at pace. */ -const STEER_SPEED_FALLOFF = 12; +const STEER_SPEED_FALLOFF = 20; /** Below this, lifting off should bring the car to rest rather than a crawl. */ const CREEP_SPEED = 1.2; @@ -37,7 +42,7 @@ export function drive( // Steering: ease toward the target rather than snapping, and shrink the // available lock as speed rises. - const authority = 1 / (1 + Math.abs(speed) / STEER_SPEED_FALLOFF); + const authority = 1 / (1 + (Math.abs(speed) / STEER_SPEED_FALLOFF) ** 2); const target = input.steer * handling.maxSteer * authority; const rate = input.steer === 0 ? STEER_RETURN_RATE : STEER_RATE; const maxDelta = rate * handling.maxSteer * dt; diff --git a/src/physics/physics.test.ts b/src/physics/physics.test.ts index fc51619..349633f 100644 --- a/src/physics/physics.test.ts +++ b/src/physics/physics.test.ts @@ -109,6 +109,67 @@ describe('slowing down', () => { }); }); +/** Seconds to swing the nose through a quarter turn from a given entry speed. */ +async function quarterTurn(entrySpeed: number) { + const physics = await createPhysics(generateWorld(1, 0)); + const state = createDriveState(); + const handling = deriveHandling(freshCondition()); + + // Get up to the speed you would actually take a junction at. + while (physics.vehicle.currentVehicleSpeed() < entrySpeed) { + drive(physics, state, { ...IDLE, throttle: 1 }, handling, STEP); + physics.step(STEP); + } + + const yawOf = () => { + const r = physics.chassis.rotation(); + return Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x)); + }; + let turned = 0; + let previous = yawOf(); + let time = 0; + let travelled = 0; + + for (let i = 0; i < 8 / STEP && turned < Math.PI / 2; i++) { + drive(physics, state, { ...IDLE, throttle: 0.4, steer: 1 }, handling, STEP); + physics.step(STEP); + const now = yawOf(); + let delta = now - previous; + // Unwrap, so crossing ±π does not read as a huge jump. + if (delta > Math.PI) delta -= Math.PI * 2; + if (delta < -Math.PI) delta += Math.PI * 2; + turned += Math.abs(delta); + previous = now; + time += STEP; + travelled += Math.abs(physics.vehicle.currentVehicleSpeed()) * STEP; + } + // Radius, not time, is what a corner costs you: a fast car sweeps through + // ninety degrees *quicker* than a slow one, just across far more tarmac. + return { turned, time, radius: travelled / Math.max(turned, 1e-6) }; +} + +describe('taking a junction', () => { + it('gets round a right-angle corner at junction speed', async () => { + // The map is a grid of 90-degree turns. If the car cannot make one at a + // sane approach speed, the whole road network fights the player. + const turn = await quarterTurn(9); + expect(turn.turned).toBeGreaterThanOrEqual(Math.PI / 2); + expect(turn.time).toBeLessThan(3); + // Tight enough to stay inside a junction rather than swinging into the + // buildings on the far side of it. + expect(turn.radius).toBeLessThan(14); + }); + + it('costs you road, not steering, as speed rises', async () => { + const slow = await quarterTurn(6); + const fast = await quarterTurn(22); + // Both corners get made; the fast one just eats far more tarmac doing it. + expect(slow.turned).toBeGreaterThanOrEqual(Math.PI / 2); + expect(fast.turned).toBeGreaterThanOrEqual(Math.PI / 2); + expect(fast.radius).toBeGreaterThan(slow.radius * 1.5); + }); +}); + describe('vehicle', () => { it('settles on its suspension instead of sinking or bouncing away', async () => { const r = await run({}, 2); @@ -131,7 +192,10 @@ describe('vehicle', () => { const turning = await run({ throttle: 1, steer: 1 }, 5); // Position is a poor check here — a hard turn loops back near the start. expect(turning.maxYawRate).toBeGreaterThan(0.3); - expect(turning.maxYawRate).toBeLessThan(1.6); + // Loosened when steering was sharpened for junctions: at low speed under + // full lock the car now comes round at about 100 deg/s, which is tight but + // is what makes the map's right angles drivable. + expect(turning.maxYawRate).toBeLessThan(2.2); expect(straight.maxYawRate).toBeLessThan(0.05); }); diff --git a/src/physics/physics.ts b/src/physics/physics.ts index 730199d..ed5665c 100644 --- a/src/physics/physics.ts +++ b/src/physics/physics.ts @@ -18,6 +18,16 @@ export interface PhysicsWorld { 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 { @@ -147,6 +157,22 @@ export async function createPhysics(model: WorldModel): Promise { 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; diff --git a/src/sim/car.ts b/src/sim/car.ts index 3f273fa..e80afd9 100644 --- a/src/sim/car.ts +++ b/src/sim/car.ts @@ -60,7 +60,7 @@ export function deriveHandling(c: CarCondition): Handling { // Lifting off has to actually slow you down. Without this the car coasts // almost forever and every stop needs a deliberate stab at the brake. coastBrake: lerp(4, 9, engine), - maxSteer: lerp(0.35, 0.55, chassis), + maxSteer: lerp(0.4, 0.62, chassis), // Bald tyres are the most legible failure: the back end starts to leave. frictionSlip: lerp(1.6, 5, tires), sideFrictionStiffness: lerp(0.5, 1, tires), diff --git a/src/sim/quests.test.ts b/src/sim/quests.test.ts index a0ca3d8..5664e2f 100644 --- a/src/sim/quests.test.ts +++ b/src/sim/quests.test.ts @@ -4,7 +4,15 @@ import { placeBases, baseAt } from './bases'; import { buildGraph, findRoute } from './routing'; import { createIntel, observe, summariseRoute, survey, hasSeen } from './intel'; import { createHeat, stepHeat } from './heat'; -import { accept, createQuests, MISSION_SHAPE, offersAt, stepQuest } from './quests'; +import { + accept, + createQuests, + MISSION_SHAPE, + MISSION_TYPES, + offersAt, + rewardFor, + stepQuest, +} from './quests'; const world = generateWorld(1337); const graph = buildGraph(world.roads); @@ -139,19 +147,14 @@ describe('quest board', () => { }); it('pays more for an unknown target, or nobody would ever take one', () => { - // Same target, same route — the only difference is whether it is known. - const state = createQuests(); - const blank = createIntel(world.roads, world.extent); - const known = createIntel(world.roads, world.extent); - for (const n of world.roads.nodes) known.visitedNodes.add(n.id); - - const rewardPerKm = (intel: typeof blank) => { - const offers = offersAt(state, world.roads, graph, intel, bases[0]!, 0, 3); - return offers.map((o) => o.reward / (o.route.length / 1000)); - }; - const novelRates = rewardPerKm(blank); - const knownRates = rewardPerKm(known); - expect(Math.min(...novelRates)).toBeGreaterThan(Math.max(...knownRates)); + // Compared on the *same* route. An earlier version of this test compared + // rates across whatever targets each board happened to offer, which mostly + // measured route length: the reward has a fixed component, so a short trip + // always looks better per kilometre whether or not it is novel. + const route = findRoute(graph, bases[0]!.nodeId, bases[1]!.nodeId)!; + for (const type of MISSION_TYPES) { + expect(rewardFor(type, route, true)).toBeGreaterThan(rewardFor(type, route, false)); + } }); it('never offers the base you are standing in as a target', () => { diff --git a/src/sim/quests.ts b/src/sim/quests.ts index da42315..874a41a 100644 --- a/src/sim/quests.ts +++ b/src/sim/quests.ts @@ -123,7 +123,8 @@ const CARGO_FRAGILITY = 1.4e-6; const NOVELTY_BONUS = 1.8; const PARTS_PER_KM = 0.1; -function rewardFor(type: MissionType, route: Route, novel: boolean): number { +/** Exported so the novelty premium can be tested on like for like. */ +export function rewardFor(type: MissionType, route: Route, novel: boolean): number { const base = 0.08 + (route.length / 1000) * PARTS_PER_KM; return base * (novel ? NOVELTY_BONUS : 1) * MISSION_SHAPE[type].reward; } diff --git a/src/sim/regions.test.ts b/src/sim/regions.test.ts index ceb8268..448e085 100644 --- a/src/sim/regions.test.ts +++ b/src/sim/regions.test.ts @@ -25,8 +25,13 @@ describe('front line', () => { }); it('runs through every band as you push along the axis', () => { + // Sampled around the boundaries themselves. The player now starts at the + // friendly edge of the map, so the bands sit a long way from the origin and + // a range anchored there misses liberated entirely. const seen = new Set(); - for (let d = -world.extent; d < world.extent * 2; d += 5) { + const from = front.boundaries.liberated - 200; + const to = front.boundaries.occupied + 200; + for (let d = from; d < to; d += 5) { seen.add(controlAt(front, front.axis.x * d, front.axis.z * d)); } expect([...seen].sort()).toEqual([...CONTROLS].sort()); diff --git a/src/sim/regions.ts b/src/sim/regions.ts index 9473073..fb76f27 100644 --- a/src/sim/regions.ts +++ b/src/sim/regions.ts @@ -90,6 +90,19 @@ const PLAYER_RANGE = 60; /** Minimum metres between adjacent boundaries, so bands cannot cross over. */ const MIN_BAND = 40; +/** + * The direction the war runs in, from the seed alone. + * + * Exposed separately because world generation needs it before there is a front: + * the starting base is placed at the friendly *edge* of the map rather than in + * the middle of it, so that the map ahead of the player is enemy ground instead + * of half the world sitting harmlessly behind their own line. + */ +export function frontAxis(seed: number): { x: number; z: number } { + const angle = makeRng(seed ^ 0x51ed270b)() * Math.PI * 2; + return { x: Math.cos(angle), z: Math.sin(angle) }; +} + export function createFront(seed: number, extent: number, home: { x: number; z: number }): Front { const rng = makeRng(seed ^ 0x51ed270b); const angle = rng() * Math.PI * 2; @@ -98,10 +111,20 @@ export function createFront(seed: number, extent: number, home: { x: number; z: // Anchor the bands relative to home, so the player always starts safely // behind their own line rather than wherever the seed happened to put it. const at = home.x * axis.x + home.z * axis.z; + /** + * Where the bands sit relative to home. + * + * Measured rather than guessed, since the bands are strips across a square + * and the axis runs diagonally, so area is nowhere near linear in depth. + * These give roughly 15% liberated, 25% contested, 27% occupied, 33% + * frontier: a home pocket you can get back to, and a map that is mostly war. + * Pulling them in tighter collapses liberated to a sliver and makes the + * frontier three quarters of the world. + */ const baseline: Boundaries = { - liberated: at + extent * 0.3, - contested: at + extent * 0.75, - occupied: at + extent * 1.3, + liberated: at + extent * 0.45, + contested: at + extent * 0.95, + occupied: at + extent * 1.5, }; const zero = (): Boundaries => ({ liberated: 0, contested: 0, occupied: 0 }); const front: Front = { diff --git a/src/sim/world.ts b/src/sim/world.ts index 7404445..0bca4d0 100644 --- a/src/sim/world.ts +++ b/src/sim/world.ts @@ -7,6 +7,7 @@ */ import { makeRng, randRange, type Rng } from '../core/rng'; import { distanceToRoad, generateRoads, type RoadNetwork } from './roads'; +import { frontAxis } from './regions'; export interface Obstacle { x: number; @@ -52,9 +53,12 @@ export function generateWorld(seed: number, count = 1800, extent = 400): WorldMo const roads = generateRoads(seed, extent); const obstacles: Obstacle[] = []; - const spawnNode = roads.nodes.reduce((best, n) => - Math.hypot(n.x, n.z) < Math.hypot(best.x, best.z) ? n : best, - ); + // Start at the friendly edge of the map, not the middle of it. Starting in + // the centre put half the world behind the player's own line, where nothing + // is watching and nothing happens — most of the map was safe by geometry. + const axis = frontAxis(seed); + const depthOf = (n: { x: number; z: number }) => n.x * axis.x + n.z * axis.z; + const spawnNode = roads.nodes.reduce((best, n) => (depthOf(n) < depthOf(best) ? n : best)); const spawn = { x: spawnNode.x, z: spawnNode.z }; const reachOf = (o: Obstacle) => Math.hypot(o.width, o.depth) / 2; diff --git a/src/unitBodies.test.ts b/src/unitBodies.test.ts new file mode 100644 index 0000000..b6613a2 --- /dev/null +++ b/src/unitBodies.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; +import { createUnitBodies } from './unitBodies'; +import { createPhysics } from './physics/physics'; +import { createUnits, type Unit, type UnitState } from './sim/units'; +import { generateWorld } from './sim/world'; + +const world = generateWorld(1, 0); + +function place(state: UnitState, overrides: Partial): Unit { + const unit: Unit = { + id: state.nextId++, + kind: 'soldier', + faction: 'enemy', + role: 'fighter', + x: 0, + z: 0, + heading: 0, + speed: 0, + hp: 30, + path: [], + expires: 999, + assigned: null, + onStation: 0, + cooldown: 0, + elevation: 1.2, + ...overrides, + }; + state.units.push(unit); + return unit; +} + +describe('running people over', () => { + it('puts down someone you drive through at speed', async () => { + const physics = await createPhysics(world); + const contacts = createUnitBodies(physics); + const units = createUnits(); + const victim = place(units, { x: 1, z: 0 }); + + const events = contacts.sync(units, { x: 0, z: 0 }, 14, 1 / 60); + expect(events.ranOver).toContain(victim); + expect(victim.hp).toBe(0); + }); + + it('does not hurt anyone you creep past', async () => { + const physics = await createPhysics(world); + const contacts = createUnitBodies(physics); + const units = createUnits(); + const bystander = place(units, { x: 1, z: 0 }); + + // Walking pace. Brushing past somebody is not running them over. + const events = contacts.sync(units, { x: 0, z: 0 }, 1.5, 1 / 60); + expect(events.ranOver).toHaveLength(0); + expect(bystander.hp).toBe(30); + }); + + it('leaves people you merely drove near alone', async () => { + const physics = await createPhysics(world); + const contacts = createUnitBodies(physics); + const units = createUnits(); + const bystander = place(units, { x: 9, z: 0 }); + + expect(contacts.sync(units, { x: 0, z: 0 }, 25, 1 / 60).ranOver).toHaveLength(0); + expect(bystander.hp).toBe(30); + }); + + it('gives people no collider, so the car is never hung up on a crowd', async () => { + const physics = await createPhysics(world); + const contacts = createUnitBodies(physics); + const units = createUnits(); + for (let i = 0; i < 12; i++) place(units, { x: i * 2, z: 4 }); + + contacts.sync(units, { x: 0, z: 0 }, 0, 1 / 60); + expect(contacts.count).toBe(0); + }); +}); + +describe('hitting other vehicles', () => { + const car = (state: UnitState, at: { x: number; z: number }) => + place(state, { kind: 'car', role: 'traffic', faction: 'civilian', hp: 60, ...at }); + + it('gives nearby vehicles a body to collide with', async () => { + const physics = await createPhysics(world); + const contacts = createUnitBodies(physics); + const units = createUnits(); + car(units, { x: 12, z: 0 }); + + const before = physics.rapier.bodies.len(); + contacts.sync(units, { x: 0, z: 0 }, 0, 1 / 60); + expect(contacts.count).toBe(1); + expect(physics.rapier.bodies.len()).toBe(before + 1); + }); + + it('damages a vehicle you ram, and not one you pull up behind', async () => { + const physics = await createPhysics(world); + const contacts = createUnitBodies(physics); + const units = createUnits(); + const rammed = car(units, { x: 3, z: 0 }); + const parked = car(units, { x: 60, z: 0 }); + + for (let i = 0; i < 30; i++) contacts.sync(units, { x: 0, z: 0 }, 20, 1 / 60); + expect(rammed.hp).toBeLessThan(60); + expect(parked.hp).toBe(60); + }); + + it('does not damage a vehicle you are simply sitting next to', async () => { + const physics = await createPhysics(world); + const contacts = createUnitBodies(physics); + const units = createUnits(); + const neighbour = car(units, { x: 3, z: 0 }); + + for (let i = 0; i < 60; i++) contacts.sync(units, { x: 0, z: 0 }, 1, 1 / 60); + expect(neighbour.hp).toBe(60); + }); + + it('takes bodies away again as traffic drives out of range', async () => { + const physics = await createPhysics(world); + const contacts = createUnitBodies(physics); + const units = createUnits(); + const passing = car(units, { x: 20, z: 0 }); + + contacts.sync(units, { x: 0, z: 0 }, 0, 1 / 60); + const withBody = physics.rapier.bodies.len(); + expect(contacts.count).toBe(1); + + passing.x = 900; + contacts.sync(units, { x: 0, z: 0 }, 0, 1 / 60); + expect(contacts.count).toBe(0); + expect(physics.rapier.bodies.len()).toBe(withBody - 1); + }); + + it('does not leak bodies as traffic comes and goes', async () => { + const physics = await createPhysics(world); + const contacts = createUnitBodies(physics); + const units = createUnits(); + const base = physics.rapier.bodies.len(); + + for (let cycle = 0; cycle < 5; cycle++) { + units.units = []; + for (let i = 0; i < 6; i++) car(units, { x: 10 + i * 6, z: 0 }); + contacts.sync(units, { x: 0, z: 0 }, 0, 1 / 60); + expect(contacts.count).toBe(6); + units.units = []; + contacts.sync(units, { x: 0, z: 0 }, 0, 1 / 60); + expect(physics.rapier.bodies.len()).toBe(base); + } + }); +}); diff --git a/src/unitBodies.ts b/src/unitBodies.ts new file mode 100644 index 0000000..77b5db5 --- /dev/null +++ b/src/unitBodies.ts @@ -0,0 +1,116 @@ +/** + * 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; + }, + }; +}