drive-between-the-lines/src/physics/physics.ts
dejvino 9ae00dc978 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>
2026-08-07 21:42:17 +02:00

214 lines
6.7 KiB
TypeScript

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<PhysicsWorld> {
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);
},
};
}