Phase 0: drivable car with persistent wear

Vite + TypeScript + three.js + Rapier raycast vehicle, fixed 60 Hz step.

Flat plate, seeded obstacle scatter, chase camera, debug HUD. Car condition
(engine/tires/chassis) degrades permanently and is derived into handling
numbers, so decline is felt through the wheel rather than read off a meter.

src/sim/ is kept free of three.js and Rapier imports — the later heat, region
and front-line systems all live there, and staying engine-free is what makes
them unit-testable without a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino
2026-08-07 07:39:05 +02:00
co-authored by Claude Opus 5
commit a66032674d
20 changed files with 2695 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
import type { DriverInput } from '../core/input';
import type { Handling } from '../sim/car';
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;
/**
* 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.
*/
const STEER_SPEED_FALLOFF = 12;
export interface DriveState {
steer: number;
}
export const createDriveState = (): DriveState => ({ steer: 0 });
/**
* Translates player intent + current car condition into wheel forces.
* This is the only place the two meet — condition arrives already digested
* into a Handling by the sim layer.
*/
export function drive(
physics: PhysicsWorld,
state: DriveState,
input: DriverInput,
handling: Handling,
dt: number,
): void {
const { vehicle } = physics;
const speed = vehicle.currentVehicleSpeed();
// 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 target = input.steer * handling.maxSteer * authority;
const rate = input.steer === 0 ? STEER_RETURN_RATE : STEER_RATE;
const maxDelta = rate * handling.maxSteer * dt;
state.steer += Math.max(-maxDelta, Math.min(maxDelta, target - state.steer));
// A bent chassis pulls constantly; the player has to hold against it.
const steerAngle = state.steer + handling.steeringPull;
// Throttle vs. brake: pressing back while rolling forward is braking, not reverse.
const wantsReverse = input.throttle < 0;
const braking = input.handbrake || (wantsReverse && speed > 1) || (input.throttle > 0 && speed < -1);
const engineForce = braking ? 0 : input.throttle * handling.engineForce;
const brakeForce = input.handbrake
? handling.brakeForce * 1.6
: braking
? handling.brakeForce
: 0;
for (let i = 0; i < WHEELS.length; i++) {
const w = WHEELS[i]!;
vehicle.setWheelSteering(i, w.steered ? steerAngle : 0);
vehicle.setWheelEngineForce(i, w.driven ? engineForce : 0);
// Handbrake locks the rear only — that is where the rotation comes from.
vehicle.setWheelBrake(i, input.handbrake && w.steered ? 0 : brakeForce);
vehicle.setWheelFrictionSlip(i, handling.frictionSlip);
vehicle.setWheelSideFrictionStiffness(i, handling.sideFrictionStiffness);
}
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { createPhysics } from './physics';
import { createDriveState, drive } from './drive';
import { deriveHandling, freshCondition } from '../sim/car';
import type { DriverInput } from '../core/input';
import { generateWorld } from '../sim/world';
const STEP = 1 / 60;
const IDLE: DriverInput = { throttle: 0, steer: 0, handbrake: false, respawn: false };
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */
async function run(input: Partial<DriverInput>, seconds: number) {
const physics = await createPhysics(generateWorld(1, 0));
const state = createDriveState();
const handling = deriveHandling(freshCondition());
const cmd = { ...IDLE, ...input };
let maxYawRate = 0;
for (let i = 0; i < Math.round(seconds / STEP); i++) {
drive(physics, state, cmd, handling, STEP);
physics.step(STEP);
maxYawRate = Math.max(maxYawRate, Math.abs(physics.chassis.angvel().y));
}
return {
pos: physics.chassis.translation(),
speed: physics.vehicle.currentVehicleSpeed(),
maxYawRate,
grounded: [0, 1, 2, 3].every((i) => physics.vehicle.wheelIsInContact(i)),
};
}
describe('vehicle', () => {
it('settles on its suspension instead of sinking or bouncing away', async () => {
const r = await run({}, 2);
expect(r.grounded).toBe(true);
expect(r.pos.y).toBeGreaterThan(0.4);
expect(r.pos.y).toBeLessThan(1.1);
expect(Math.abs(r.speed)).toBeLessThan(0.2);
});
it('accelerates forward along +Z at a plausible rate', async () => {
const r = await run({ throttle: 1 }, 5);
expect(r.pos.z).toBeGreaterThan(20);
// Roughly 40–140 km/h after five seconds: quick, but not a rocket.
expect(r.speed).toBeGreaterThan(11);
expect(r.speed).toBeLessThan(39);
});
it('turns when steered, without spinning like a top', async () => {
const straight = await run({ throttle: 1 }, 5);
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);
expect(straight.maxYawRate).toBeLessThan(0.05);
});
it('steers left on positive input', async () => {
// Forward is +Z and up is +Y, so left is +X.
const r = await run({ throttle: 1, steer: 1 }, 2);
expect(r.pos.x).toBeGreaterThan(0.2);
});
it('stays upright under power and steering', async () => {
const r = await run({ throttle: 1, steer: 1 }, 8);
expect(r.grounded).toBe(true);
});
});
+137
View File
@@ -0,0 +1,137 @@
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;
}
/** 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(CAR.spawn.x, CAR.spawn.y, CAR.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;
},
respawn() {
chassis.setTranslation({ x: CAR.spawn.x, y: CAR.spawn.y, z: CAR.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);
},
};
}