Sharper steering, solid traffic, and a map that is mostly war

Steering. The falloff with speed was linear, so the car had already lost a third
of its lock by 20km/h — exactly the speed you take a right-angle junction at, on
a map made of right-angle junctions. It is quadratic now, which leaves low
speeds nearly untouched and still calms things down at pace, plus a faster rack
and a little more lock. Pinned with a test that drives an actual quarter turn and
measures its radius. A first attempt asserted that fast corners take longer,
which is simply false: a fast car swings through ninety degrees quicker, just
across far more tarmac. Radius is what a corner costs you.

Contact. Units were points that ignored everything, so there was nothing to hit.
Vehicles now carry kinematic bodies and ramming one is a real collision that
damages them and shoves you. People get no collider on purpose — a capsule means
snagging on pedestrians or launching them — so you drive through them and they go
down, and running over a civilian is counted for later.

Territory. Liberated ground covered more than half the map because the player
started in the middle of it, so most of the world was safe by geometry. The
starting base is now at the friendly edge and the bands are anchored to give
roughly 15/25/27/33 across liberated, contested, occupied and frontier. Measured
in the running game rather than guessed, since the axis runs diagonally across a
square and area is not linear in depth.

Also fixes a fragile test that compared reward rates across whichever targets a
board happened to offer, and so mostly measured route length rather than the
novelty premium it claimed to check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-07 21:42:17 +02:00
parent 62c67ea649
commit 9ae00dc978
13 changed files with 455 additions and 30 deletions

View File

@ -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

View File

@ -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<Record<string, number>>((counts, u) => {
counts[u.role] = (counts[u.role] ?? 0) + 1;

View File

@ -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;

View File

@ -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);
});

View File

@ -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<PhysicsWorld> {
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;

View File

@ -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),

View File

@ -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', () => {

View File

@ -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;
}

View File

@ -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<string>();
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());

View File

@ -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 = {

View File

@ -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;

147
src/unitBodies.test.ts Normal file
View File

@ -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>): 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);
}
});
});

116
src/unitBodies.ts Normal file
View File

@ -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<number, RAPIER.RigidBody>();
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<number>();
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;
},
};
}