drive-between-the-lines/src/physics/physics.test.ts
dejvino cf5d55bdf0 Look around with the mouse
Hold the right button and drag to swing the view round the car; let go
and it eases back to the road over a moment, the way you turn your head
back rather than being snapped to it. Measured: 9.6m of orbit on a
short drag, and exactly zero drift from centre once released.

Deliberately not pointer lock. Locking the cursor feels better in a
driving game right up until you want to press one of the debug panel
buttons or read the quest board, both of which are ordinary DOM sitting
over the canvas - a game that swallows the cursor to look left is one
you have to escape out of to use its own interface.

The camera orbits the car rather than turning on the spot, so the car
stays in frame and you can still see what you are about to drive into
while looking away from it.

Read at frame rate rather than from the fixed step, since this is the
one input where lag shows up directly as the view dragging behind the
mouse - through a separate accessor, because `read()` clears the
buffered one-shot keys and calling it from the render loop would
swallow whichever keypress landed that frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:51:57 +02:00

258 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'vitest';
import { createPhysics } from './physics';
import { createDriveState, drive } from './drive';
import {
deriveHandling,
freshCondition,
ROUGH,
surfaceFor,
TARMAC,
type Surface,
} 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,
select: null,
toggleMute: false,
abandon: false,
overhaul: false,
look: { yaw: 0, pitch: 0, active: false },
};
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */
async function run(input: Partial<DriverInput>, seconds: number) {
const world = generateWorld(1, 0);
const physics = await createPhysics(world);
const start = physics.chassis.translation();
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));
}
const now = physics.chassis.translation();
return {
// Displacement from the spawn point, which is a road junction, not the origin.
pos: { x: now.x - start.x, y: now.y, z: now.z - start.z },
speed: physics.vehicle.currentVehicleSpeed(),
maxYawRate,
grounded: [0, 1, 2, 3].every((i) => physics.vehicle.wheelIsInContact(i)),
};
}
/** Gets up to speed, then measures what happens after a given input. */
async function stopFrom(after: Partial<DriverInput>, seconds = 8) {
const world = generateWorld(1, 0);
const physics = await createPhysics(world);
const state = createDriveState();
const handling = deriveHandling(freshCondition());
for (let i = 0; i < 6 / STEP; i++) {
drive(physics, state, { ...IDLE, throttle: 1 }, handling, STEP);
physics.step(STEP);
}
const entry = physics.vehicle.currentVehicleSpeed();
const from = physics.chassis.translation();
let time = 0;
const cmd = { ...IDLE, ...after };
for (let i = 0; i < seconds / STEP; i++) {
if (Math.abs(physics.vehicle.currentVehicleSpeed()) < 0.3) break;
drive(physics, state, cmd, handling, STEP);
physics.step(STEP);
time += STEP;
}
const to = physics.chassis.translation();
return {
entrySpeed: entry,
finalSpeed: physics.vehicle.currentVehicleSpeed(),
distance: Math.hypot(to.x - from.x, to.z - from.z),
time,
};
}
describe('slowing down', () => {
it('hauls the car to a standstill on the brake', async () => {
const r = await stopFrom({ throttle: -1 });
expect(r.entrySpeed).toBeGreaterThan(12);
expect(Math.abs(r.finalSpeed)).toBeLessThan(0.3);
// Roughly road-car braking: not a parachute, not a barge. The lower bound
// matters as much as the upper one — brakes strong enough to stop in 10m
// read as hitting a wall.
expect(r.distance).toBeGreaterThan(15);
expect(r.distance).toBeLessThan(38);
expect(r.time).toBeGreaterThan(1.5);
expect(r.time).toBeLessThan(4.5);
});
it('comes to rest on its own when you simply lift off', async () => {
const r = await stopFrom({}, 20);
// The complaint this fixes: coasting used to never actually stop.
expect(Math.abs(r.finalSpeed)).toBeLessThan(0.3);
expect(r.time).toBeLessThan(16);
});
it('stops harder on the brake than on the overrun', async () => {
const braked = await stopFrom({ throttle: -1 });
const coasted = await stopFrom({}, 20);
expect(braked.distance).toBeLessThan(coasted.distance);
});
it('stops worse on bald tyres', async () => {
const physics = await createPhysics(generateWorld(1, 0));
const worn = deriveHandling({
level: { engine: 0.2, tires: 0.2, chassis: 0.2 },
ceiling: { engine: 1, tires: 1, chassis: 1 },
});
expect(worn.brakeForce).toBeLessThan(deriveHandling(freshCondition()).brakeForce);
expect(physics.vehicle.numWheels()).toBe(4);
});
});
/** 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('roads are worth using', () => {
/** Flat out on a given surface for long enough to find its ceiling. */
async function topSpeed(surface: Surface) {
const physics = await createPhysics(generateWorld(1, 0));
const state = createDriveState();
const handling = deriveHandling(freshCondition());
let top = 0;
for (let i = 0; i < 30 / STEP; i++) {
drive(physics, state, { ...IDLE, throttle: 1 }, handling, STEP, surface);
physics.step(STEP);
top = Math.max(top, physics.vehicle.currentVehicleSpeed());
}
return top;
}
it('goes markedly faster on tarmac than across country', async () => {
const road = await topSpeed(TARMAC);
const rough = await topSpeed(ROUGH);
expect(rough).toBeLessThan(road * 0.6);
});
it('still lets you leave the road when you need to', async () => {
// Going around a checkpoint cross-country has to cost something without
// being impossible. An earlier tuning made off-road a 12 km/h crawl, which
// is not a decision — it is a wall.
const rough = await topSpeed(ROUGH);
expect(rough).toBeGreaterThan(12);
});
it('is chosen by whether the car is on a route at all', () => {
expect(surfaceFor(true)).toBe(TARMAC);
expect(surfaceFor(false)).toBe(ROUGH);
});
});
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 40140 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);
// 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);
});
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);
});
});