Back to Big Rig Rush
SOURCE / PINNED RELEASE

Made of little things.

Big Rig Rush

Release
ec9b6549f752…
Author-recorded commit
74b729258b1a…
License
LICENSE
Author’s source reference
nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-0a99f0cae40

Archive hash verified: 900e22810340ccb1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/truck.ts
import type { Controls } from '../input.js';
import { A10Flight, FLIGHT_ALT, METERS } from './a10.js';
import type { City, Material, Structure } from './city.js';
import { angleDiff, clamp, type Vec } from './geometry.js';
import {
  applyImpulse,
  axleGrip,
  boxInertia,
  forwardSpeed,
  integrate,
  invMassAlong,
  resolveStatic,
  velocityAt,
  type Body,
} from './physics.js';
import type { Dent, TruckPose } from './vehicles.js';

export interface Circle {
  x: number;
  y: number;
  r: number;
}

export interface WallHit {
  impact: number;
  x: number;
  y: number;
  material: Material;
  /** The building (or other solid block) that was hit, if any. */
  structure: Structure | null;
}

/** How a patch of ground drives: tyre grip, extra rolling drag (soft ground) and bumpiness. */
export interface SurfaceFeel {
  grip: number;
  drag: number;
  bump: number;
}

export type TruckEvent = { type: 'shift'; gear: number } | { type: 'grind' } | { type: 'limiter' } | { type: 'tipping' } | { type: 'rollover' };

/** Gameplay gravity in px/s²: sets how much grip the tyres have. */
export const GRAVITY = 60;
export const IDLE_RPM = 650;
export const REDLINE = 2100;
/** Road speed at the redline for each gear: R, N, 1-8 (px/s). */
export const GEAR_TOPS = [-20, 0, 16, 28, 42, 58, 78, 102, 132, 170];
export const GEAR_NAMES = ['R', 'N', '1', '2', '3', '4', '5', '6', '7', '8'];
export const MAX_SPEED = 170;

const DRIVE = 15000;
const SHIFT_TIME = 0.2;
const ROLLING = 0.006;
const AERO = 0.0009;
const BRAKE = 0.88;
const CAB_MASS = 7;
const TRAILER_EMPTY = 10;
const TRAILER_LOADED = 20;
const CAB_FRONT_AXLE = 5;
const CAB_REAR_AXLE = -4;
const CAB_HITCH = -7;
const TRAILER_HITCH = 17;
const TRAILER_AXLE = -13;
const JACKKNIFE = 1.5;
const MAX_DENTS = 16;

/** What the player drives. */
export type VehicleKind = 'rig' | 'sports' | 'dozer' | 'a10';

/**
 * The bulldozer: about 28 tonnes on tracks. Tank steering that turns on the spot, a
 * top speed a little brisker than a real dozer, tracks that never slide sideways, and
 * enough weight to shove anything out of the way.
 */
const DOZER = {
  mass: 28,
  halfL: 8.5,
  halfW: 7,
  idle: 700,
  redline: 2200,
  tops: [-20, 0, 34],
  names: ['R', 'N', 'F'],
  accel: 15,
  brake: 70,
  turn: 1.35,
};

/**
 * The A-10 Warthog: it flies over everything (its own layer), always moving forward.
 * W opens the throttle, S closes it (down to a slow loiter), A/D bank it round.
 */
const A10 = {
  mass: 12,
  halfL: 10,
  halfW: 11,
  idle: 0,
  redline: 1,
  // About 720 km/h flat out (the sound and HUD scale); the flight model sets the real limit.
  tops: [0, 0, 200 / METERS],
  names: ['R', 'N', 'A'],
};

/** The A-10 flies on its own layer, above the deck and everything on it. */
export const AIR_LAYER = 2;

/** The sports car: a light, grippy, rear-wheel-drive coupe with a high-revving six-speed. */
const SPORTS = {
  mass: 1.4,
  halfL: 8.5,
  halfW: 4.2,
  frontAxle: 5,
  rearAxle: -5,
  drive: 5600,
  idle: 900,
  redline: 7600,
  tops: [-38, 0, 46, 78, 110, 145, 182, 220],
  names: ['R', 'N', '1', '2', '3', '4', '5', '6'],
  aero: 0.00032,
  rolling: 0.004,
  brake: 1.25,
  grip: 1.8,
};

function torque(rpm: number): number {
  const x = (rpm - 1300) / 1300;
  return 1 - x * x * 0.6;
}

function local(b: Body, lx: number, ly = 0): Vec {
  const c = Math.cos(b.angle);
  const s = Math.sin(b.angle);
  return { x: b.x + c * lx - s * ly, y: b.y + s * lx + c * ly };
}

function makeBody(x: number, y: number, angle: number, mass: number, halfL: number, halfW: number): Body {
  return { x, y, vx: 0, vy: 0, angle, av: 0, mass, inertia: boxInertia(mass, halfL, halfW), halfL, halfW };
}

/** Push along a body's heading at a point, limited to `max` impulse. Opposes motion when `oppose` is set. */
function longitudinal(b: Body, p: Vec, heading: number, impulse: number, oppose: boolean): void {
  const dx = Math.cos(heading);
  const dy = Math.sin(heading);
  if (!oppose) {
    applyImpulse(b, dx * impulse, dy * impulse, p.x, p.y);
    return;
  }
  const v = velocityAt(b, p.x, p.y);
  const vl = v.x * dx + v.y * dy;
  const stop = Math.abs(vl) / invMassAlong(b, p.x, p.y, dx, dy);
  const j = -Math.sign(vl) * Math.min(impulse, stop);
  applyImpulse(b, dx * j, dy * j, p.x, p.y);
}

export class Truck {
  readonly cab: Body;
  /** The rig's trailer. A sports car has none: this is a tiny marker glued to the car. */
  readonly trailer: Body;
  /** Road speed at the redline per gear (R, N, 1…), top speed, idle and redline revs. */
  readonly tops: number[];
  readonly names: string[];
  readonly maxSpeed: number;
  readonly idle: number;
  readonly redline: number;
  gear = 2;
  rpm = IDLE_RPM;
  steer = 0;
  damage = 0;
  layer = 0;
  loaded = false;
  throttle = false;
  braking = false;
  skidding = false;
  limiter = false;
  shiftTimer = 0;
  /** Body roll (radians): wheels lift past about 0.15, the rig is on its side at ±π/2. */
  roll = 0;
  rollVel = 0;
  rolled = false;
  private tipWarned = false;
  /** Slip per axle: cab front, cab rear, trailer. */
  readonly slip = [false, false, false];
  readonly cabDents: Dent[] = [];
  readonly trailerDents: Dent[] = [];
  readonly events: TruckEvent[] = [];
  /** Distance travelled (for the dozer's track treads). */
  odometer = 0;
  /** Feel of the ground under the driven wheels (for dust, mud spray and the HUD). */
  ground: SurfaceFeel = { grip: 1, drag: 0, bump: 0 };
  private standstill = 0;

  constructor(
    x: number,
    y: number,
    angle: number,
    readonly kind: VehicleKind = 'rig',
  ) {
    if (kind !== 'rig') {
      const spec = kind === 'sports' ? SPORTS : kind === 'dozer' ? DOZER : A10;
      this.cab = makeBody(x, y, angle, spec.mass, spec.halfL, spec.halfW);
      this.trailer = makeBody(x, y, angle, 0.01, 0.4, 0.4);
      this.tops = spec.tops;
      this.names = spec.names;
      this.idle = spec.idle;
      this.redline = spec.redline;
    } else {
      this.cab = makeBody(x, y, angle, CAB_MASS, 9, 6);
      const h = local(this.cab, CAB_HITCH);
      this.trailer = makeBody(h.x - Math.cos(angle) * TRAILER_HITCH, h.y - Math.sin(angle) * TRAILER_HITCH, angle, TRAILER_EMPTY, 19, 6);
      this.tops = GEAR_TOPS;
      this.names = GEAR_NAMES;
      this.idle = IDLE_RPM;
      this.redline = REDLINE;
    }
    this.maxSpeed = this.tops[this.tops.length - 1];
    this.rpm = this.idle;
    if (kind === 'a10') {
      this.layer = AIR_LAYER;
      this.flight = new A10Flight(angle);
      this.syncFlight();
    }
  }

  get sports(): boolean {
    return this.kind === 'sports';
  }

  get dozer(): boolean {
    return this.kind === 'dozer';
  }

  get plane(): boolean {
    return this.kind === 'a10';
  }

  /** Bank angle for drawing the plane (radians, + is right wing down). */
  bank = 0;

  /** The A-10's flight state (height, airspeed, flight path, engines); null on the ground. */
  readonly flight: A10Flight | null = null;

  /** Height above the ground in map units (0 for anything on wheels or tracks). */
  get altitude(): number {
    return this.flight ? FLIGHT_ALT * (1 - this.flight.fall) : 0;
  }

  /** No trailer: the sports car and the dozer are a single body. */
  get solo(): boolean {
    return this.kind !== 'rig';
  }

  get x(): number {
    return this.cab.x;
  }

  get y(): number {
    return this.cab.y;
  }

  get angle(): number {
    return this.cab.angle;
  }

  get trailerAngle(): number {
    return this.trailer.angle;
  }

  /** Signed speed along the cab's heading (the A-10: its airspeed). */
  get speed(): number {
    return this.flight ? this.flight.v / METERS : forwardSpeed(this.cab);
  }

  get gearName(): string {
    return this.names[this.gear];
  }

  trailerCenter(): Vec {
    return { x: this.trailer.x, y: this.trailer.y };
  }

  setLoaded(loaded: boolean): void {
    this.loaded = loaded;
    // A sports car's "cargo" is a parcel in the passenger seat; the dozer carries it in the blade.
    if (this.solo) return;
    this.trailer.mass = loaded ? TRAILER_LOADED : TRAILER_EMPTY;
    this.trailer.inertia = boxInertia(this.trailer.mass, this.trailer.halfL, this.trailer.halfW);
  }

  /** The sports car has an automatic gearbox: it picks its own gears. */
  get automatic(): boolean {
    return this.solo;
  }

  shiftUp(): void {
    if (this.automatic) return;
    this.shiftTo(Math.min(this.tops.length - 1, this.gear + 1));
  }

  shiftDown(): void {
    if (this.automatic) return;
    this.shiftTo(Math.max(0, this.gear - 1));
  }

  private shiftTo(gear: number): void {
    if (gear === this.gear) return;
    const v = this.speed;
    if (gear === 0 && v > 3) {
      this.events.push({ type: 'grind' });
      return;
    }
    if (gear >= 2) {
      const rpm = (v / this.tops[gear]) * this.redline;
      if (rpm > this.redline * 1.12 || (this.gear === 0 && v < -3)) {
        this.events.push({ type: 'grind' });
        return;
      }
    }
    this.gear = gear;
    this.shiftTimer = gear === 1 ? 0 : this.sports ? 0.09 : SHIFT_TIME;
    this.events.push({ type: 'shift', gear });
  }

  circles(): Circle[] {
    if (this.solo) return [{ ...local(this.cab, 0), r: this.dozer ? 9 : 6 }];
    return [
      { ...local(this.cab, 1), r: 7 },
      ...[12, 0, -12].map((d) => ({ ...local(this.trailer, d), r: 6 })),
    ];
  }

  /** Wheel positions of axles that are currently sliding (for skid marks). */
  slidingWheels(): Vec[] {
    const out: Vec[] = [];
    if (this.rolled || this.dozer || this.plane) return out;
    if (this.sports) {
      const rearSlip = this.slip[1] || (this.braking && Math.abs(this.speed) > 40);
      if (this.slip[0]) out.push(local(this.cab, SPORTS.frontAxle, -3.4), local(this.cab, SPORTS.frontAxle, 3.4));
      if (rearSlip) out.push(local(this.cab, SPORTS.rearAxle, -3.4), local(this.cab, SPORTS.rearAxle, 3.4));
      return out;
    }
    const axles: [Body, number, boolean][] = [
      [this.cab, CAB_FRONT_AXLE, this.slip[0]],
      [this.cab, CAB_REAR_AXLE, this.slip[1] || (this.braking && Math.abs(this.speed) > 30)],
      [this.trailer, TRAILER_AXLE, this.slip[2] || (this.braking && Math.abs(this.speed) > 30)],
    ];
    for (const [b, ax, on] of axles) {
      if (!on) continue;
      out.push(local(b, ax, -5), local(b, ax, 5));
    }
    return out;
  }

  pose(): TruckPose {
    return {
      x: this.cab.x,
      y: this.cab.y,
      angle: this.cab.angle,
      trailer: { x: this.trailer.x, y: this.trailer.y },
      trailerAngle: this.trailer.angle,
      braking: this.braking,
      cabDents: this.cabDents,
      trailerDents: this.trailerDents,
      steer: this.steer,
      roll: this.roll,
    };
  }

  /** Record a dent at a world-space point on whichever part is closer. */
  dent(wx: number, wy: number, size: number): void {
    const onCab = Math.hypot(wx - this.cab.x, wy - this.cab.y) < Math.hypot(wx - this.trailer.x, wy - this.trailer.y);
    const b = onCab ? this.cab : this.trailer;
    const list = onCab ? this.cabDents : this.trailerDents;
    const dx = wx - b.x;
    const dy = wy - b.y;
    const c = Math.cos(b.angle);
    const s = Math.sin(b.angle);
    list.push({
      x: clamp(dx * c + dy * s, -b.halfL, b.halfL),
      y: clamp(-dx * s + dy * c, -b.halfW, b.halfW),
      r: Math.min(3.6, 0.8 + size),
    });
    if (list.length > MAX_DENTS) list.shift();
  }

  /** Advance the rig. `grip` is the surface friction under the cab. Returns wall impacts. */
  update(dt: number, raw: Controls, feel: (x: number, y: number) => SurfaceFeel, city: City): WallHit[] {
    const hits: WallHit[] = [];
    const controls = this.plane ? raw : this.pedals(dt, raw);
    if (this.sports) this.autoShift(controls);
    this.throttle = controls.up;
    this.braking = controls.down && Math.abs(this.speed) > 1;
    this.limiter = false;
    const steerTarget = (controls.right ? 1 : 0) - (controls.left ? 1 : 0);
    this.steer += (steerTarget - this.steer) * Math.min(1, dt * (this.plane ? 14 : 8));

    const steps = Math.max(1, Math.ceil(dt / 0.008));
    const h = dt / steps;
    if (this.plane) {
      this.stepAir(dt, controls);
      this.glueMarker();
      this.odometer += this.speed * dt;
      return hits;
    }
    for (let i = 0; i < steps; i++) {
      if (this.sports) this.stepSports(h, controls, feel);
      else if (this.dozer) this.stepDozer(h, controls, feel);
      else this.step(h, controls, feel);
      this.collideWorld(city, hits);
      if (this.solo) this.glueMarker();
    }
    this.odometer += this.speed * dt;
    this.skidding = this.slip.some(Boolean) || (this.braking && Math.abs(this.speed) > 30);
    return hits;
  }

  private step(h: number, controls: Controls, feel: (x: number, y: number) => SurfaceFeel): void {
    const cab = this.cab;
    const trailer = this.trailer;
    const v = forwardSpeed(cab);
    this.shiftTimer = Math.max(0, this.shiftTimer - h);
    if (this.rolled) {
      this.slideOnSide(h);
      return;
    }

    // Engine and gearbox.
    const top = GEAR_TOPS[this.gear];
    let drive = 0;
    if (this.gear === 1) {
      const target = controls.up ? REDLINE * 0.95 : IDLE_RPM;
      this.rpm += (target - this.rpm) * Math.min(1, h * 5);
    } else {
      const wheelRpm = (v / top) * REDLINE;
      const target = Math.max(IDLE_RPM, wheelRpm);
      this.rpm += (target - this.rpm) * Math.min(1, h * (this.shiftTimer > 0 ? 6 : 25));
      if (wheelRpm >= REDLINE) {
        this.limiter = true;
      } else if (controls.up && this.shiftTimer <= 0) {
        drive = (torque(this.rpm) * DRIVE) / Math.abs(top);
      }
      if (!controls.up && this.shiftTimer <= 0 && wheelRpm > IDLE_RPM) {
        drive = -Math.sign(top) * 30 * (this.rpm / REDLINE);
      }
      drive *= Math.sign(top);
    }

    // Axle loads (the trailer's nose rests on the cab's rear axle).
    const frontN = 0.45 * cab.mass * GRAVITY;
    const rearN = (0.55 * cab.mass + 0.35 * trailer.mass) * GRAVITY;
    const trailerN = 0.65 * trailer.mass * GRAVITY;
    const front = local(cab, CAB_FRONT_AXLE);
    const rear = local(cab, CAB_REAR_AXLE);
    const tAxle = local(trailer, TRAILER_AXLE);
    // Each axle feels its own patch of ground. The heavy rig sinks into soft ground.
    const fF = feel(front.x, front.y);
    const fR = feel(rear.x, rear.y);
    const fT = feel(tAxle.x, tAxle.y);
    this.ground = fR;
    const mu = fR.grip;
    this.bump(cab, v, fF, h);

    // Drive force through the rear axle, limited by traction (wheelspin).
    // Drive is held a little under the traction limit so the tyres keep some sideways grip
    // (full wheelspin on loose ground would leave none, and the rig would spin out).
    const traction = mu * rearN;
    const driveForce = clamp(drive, -traction * 0.85, traction * 0.85);
    const spinning = Math.abs(drive) > traction * 1.02;
    if (driveForce) longitudinal(cab, rear, cab.angle, driveForce * h, false);

    // Brakes, rolling resistance (worse on soft ground) and air drag oppose motion at every axle.
    const brake = controls.down ? BRAKE * Math.min(1, mu + 0.1) : 0;
    for (const [b, p, n, f] of [
      [cab, front, frontN, fF],
      [cab, rear, rearN, fR],
      [trailer, tAxle, trailerN, fT],
    ] as [Body, Vec, number, SurfaceFeel][]) {
      // Soft ground ploughs: the faster you go, the harder it holds you back.
      longitudinal(b, p, b.angle, (brake * Math.min(1, f.grip + 0.1) + ROLLING + f.drag * 1.3 * (1 + Math.abs(v) / 90)) * n * h, true);
    }
    for (const b of [cab, trailer]) {
      const speed = Math.hypot(b.vx, b.vy);
      if (speed > 0) {
        const f = Math.min(speed * b.mass, AERO * speed * speed * (b === cab ? 1 : 0.6) * h);
        b.vx -= (b.vx / speed) * (f / b.mass);
        b.vy -= (b.vy / speed) * (f / b.mass);
      }
    }

    // Lateral grip (friction circle: whatever the axle spends on drive/brake is lost sideways).
    const steerAngle = this.steer * 0.62 * (1 - Math.min(0.78, Math.abs(v) / 170));
    const rearLong = Math.abs(driveForce) + brake * rearN;
    const rearLat = Math.sqrt(Math.max(0, (mu * rearN) ** 2 - rearLong ** 2));
    this.slip[0] = axleGrip(cab, front.x, front.y, cab.angle + steerAngle, fF.grip * frontN * h);
    // Tandem drive and trailer axles: eight tyres each, so more grip than the steer axle.
    this.slip[1] = axleGrip(cab, rear.x, rear.y, cab.angle, rearLat * 1.55 * h) || spinning;
    this.slip[2] = axleGrip(trailer, tAxle.x, tAxle.y, trailer.angle, fT.grip * 1.55 * trailerN * (1 - brake * 0.6) * h);

    // Rollover: cornering force beyond what the trailer's centre of gravity can take
    // lifts the inside wheels; past the tipping point gravity finishes the job.
    const aLat = forwardSpeed(trailer) * trailer.av;
    const threshold = (this.loaded ? 0.66 : 0.95) * GRAVITY;
    const excess = Math.abs(aLat) - threshold;
    if (excess > 0) this.rollVel += Math.sign(aLat) * excess * 0.16 * h;
    if (Math.abs(this.roll) < 0.38) this.rollVel -= this.roll * 6 * h;
    else this.rollVel += Math.sign(this.roll) * 8 * h;
    this.rollVel *= Math.exp(-2.2 * h);
    this.roll += this.rollVel * h;
    if (Math.abs(this.roll) > 0.15 && !this.tipWarned) {
      this.tipWarned = true;
      this.events.push({ type: 'tipping' });
    }
    if (Math.abs(this.roll) < 0.05) this.tipWarned = false;
    if (Math.abs(this.roll) >= 1.45) {
      this.rolled = true;
      this.roll = (Math.sign(this.roll) * Math.PI) / 2;
      this.rollVel = 0;
      this.events.push({ type: 'rollover' });
    }

    // Stability assist: damp the cab swinging away from the trailer at speed, so
    // the rig is hard to jackknife by accident.
    if (Math.abs(v) > 30) {
      const rel = cab.av - trailer.av;
      const assist = Math.min(1, h * 6 * Math.min(1, Math.abs(v) / 80));
      cab.av -= rel * assist * 0.5;
    }

    // Fifth-wheel hitch keeps cab and trailer joined.
    this.hitchConstraint(h);
    this.jackknifeStop();
    integrate(cab, h);
    integrate(trailer, h);
  }

  /**
   * One pedal for stop and back: S brakes, and held at a standstill it drops into
   * reverse and backs up; in reverse, W brakes and then pulls away in first. Returns
   * the controls as the engine sees them (throttle and brake swap in reverse).
   */
  private pedals(dt: number, c: Controls): Controls {
    const v = this.speed;
    const still = Math.abs(v) < 1.5;
    this.standstill = still ? this.standstill + dt : 0;
    if (this.gear === 0) {
      if (c.up && !c.down && v > -1.5) this.engage(2);
      else return { ...c, up: c.down, down: c.up };
    } else if (c.down && !c.up && still && this.standstill > 0.15 && !this.rolled) {
      this.engage(0);
      return { ...c, up: true, down: false };
    }
    return c;
  }

  /**
   * Automatic gearbox: short-shifts when cruising, holds gears to near the redline
   * under full throttle, kicks down for a burst of power, and drops through the gears
   * as you slow so it pulls away in first.
   */
  private autoShift(c: Controls): void {
    if (this.gear < 1 || this.shiftTimer > 0 || this.rolled) return;
    if (this.gear === 1) {
      if (c.up) this.engage(2);
      return;
    }
    const v = Math.max(0, this.speed);
    const rpmIn = (g: number) => (v / this.tops[g]) * this.redline;
    const top = this.tops.length - 1;
    const up = c.up ? 0.9 : 0.55;
    const down = c.up ? 0.5 : 0.3;
    if (this.gear < top && rpmIn(this.gear) > this.redline * up && rpmIn(this.gear + 1) > this.redline * 0.35) {
      this.shiftAuto(this.gear + 1);
    } else if (this.gear > 2 && rpmIn(this.gear) < this.redline * down && rpmIn(this.gear - 1) < this.redline * 0.85) {
      this.shiftAuto(this.gear - 1);
    }
  }

  private shiftAuto(gear: number): void {
    this.gear = gear;
    this.shiftTimer = 0.12;
    this.events.push({ type: 'shift', gear });
  }

  private engage(gear: number): void {
    if (this.gear === gear) return;
    this.gear = gear;
    this.shiftTimer = 0;
    this.events.push({ type: 'shift', gear });
  }

  /** The sports car's "trailer" is just a marker riding at its centre. */
  private glueMarker(): void {
    const t = this.trailer;
    const c = this.cab;
    t.x = c.x;
    t.y = c.y;
    t.vx = c.vx;
    t.vy = c.vy;
    t.angle = c.angle;
    t.av = c.av;
  }

  /**
   * Sports-car physics: a light rear-drive coupe. Lots of power for its weight (it can
   * light up the rear tyres in the low gears), sticky tyres, quick steering that tightens
   * less at speed than the rig's, strong brakes, little drag, and no rollover.
   */
  /** Flight: the A-10's own model does the work; the body just follows it over the map. */
  private stepAir(dt: number, controls: Controls): void {
    const f = this.flight!;
    const steps = Math.max(1, Math.ceil(dt / 0.01));
    for (let i = 0; i < steps; i++) {
      f.step(dt / steps, { roll: this.steer, throttleUp: controls.throttleUp || controls.up, throttleDown: controls.throttleDown || controls.down });
      const gs = f.v / METERS;
      this.cab.x += Math.cos(f.heading) * gs * (dt / steps);
      this.cab.y += Math.sin(f.heading) * gs * (dt / steps);
    }
    this.syncFlight();
    this.throttle = f.lever > 0.75;
    this.braking = false;
    this.slip[0] = this.slip[1] = this.slip[2] = false;
  }

  /** Point the body where the flight model says, at its ground speed. */
  syncFlight(): void {
    const f = this.flight!;
    const gs = f.v / METERS;
    this.cab.angle = f.heading;
    this.cab.av = 0;
    this.cab.vx = Math.cos(f.heading) * gs;
    this.cab.vy = Math.sin(f.heading) * gs;
    this.bank = f.bank;
    this.rpm = f.spool;
  }

  /**
   * Bulldozer: the tracks drive it straight to the speed asked for (slower on soft
   * ground), stop it hard when you let go, and never slide sideways. Steering spins
   * it on its tracks, standing still or moving.
   */
  private stepDozer(h: number, controls: Controls, feel: (x: number, y: number) => SurfaceFeel): void {
    const cab = this.cab;
    const c = Math.cos(cab.angle);
    const s = Math.sin(cab.angle);
    const v = forwardSpeed(cab);
    const f = feel(cab.x, cab.y);
    this.ground = f;
    const soft = 1 - Math.min(0.35, f.drag * 4);
    const top = this.tops[this.gear] * soft;
    const target = this.gear === 1 ? 0 : controls.up ? top : 0;
    const accelerating = Math.abs(target) > Math.abs(v) && Math.sign(target || 1) === Math.sign(v || target || 1);
    const limit = (accelerating ? DOZER.accel : DOZER.brake) * h;
    const dv = clamp(target - v, -limit, limit);
    cab.vx += c * dv;
    cab.vy += s * dv;
    // Tracks: no sideways sliding.
    const lat = -s * cab.vx + c * cab.vy;
    const kill = lat * (1 - Math.exp(-14 * h));
    cab.vx += s * kill;
    cab.vy -= c * kill;
    // Tank steering: turn on the spot, a touch slower at speed.
    const yaw = this.steer * DOZER.turn * (1 - 0.3 * Math.min(1, Math.abs(v) / 34));
    cab.av += (yaw - cab.av) * Math.min(1, h * 7);
    // Engine note follows the load: tracks, blade and turning.
    const load = Math.abs(v) / 34 + (controls.up ? 0.25 : 0) + Math.abs(this.steer) * 0.2;
    this.rpm += (this.idle + Math.min(1, load) * (this.redline - this.idle) * 0.9 - this.rpm) * Math.min(1, h * 4);
    this.slip[0] = this.slip[1] = this.slip[2] = false;
    this.roll = 0;
    integrate(cab, h);
  }

  /** Ruts and tussocks knock the steering about on rough ground. */
  private bump(b: Body, v: number, f: SurfaceFeel, h: number): void {
    if (!f.bump || Math.abs(v) < 8) return;
    b.av += (Math.random() - 0.5) * f.bump * Math.min(1, Math.abs(v) / 80) * 2.5 * h;
  }

  private stepSports(h: number, controls: Controls, feel: (x: number, y: number) => SurfaceFeel): void {
    const cab = this.cab;
    const v = forwardSpeed(cab);
    this.shiftTimer = Math.max(0, this.shiftTimer - h);
    const top = this.tops[this.gear];
    let drive = 0;
    if (this.gear === 1) {
      const target = controls.up ? this.redline * 0.95 : this.idle;
      this.rpm += (target - this.rpm) * Math.min(1, h * 9);
    } else {
      const wheelRpm = (v / top) * this.redline;
      this.rpm += (Math.max(this.idle, wheelRpm) - this.rpm) * Math.min(1, h * (this.shiftTimer > 0 ? 10 : 30));
      if (wheelRpm >= this.redline) this.limiter = true;
      else if (controls.up && this.shiftTimer <= 0) drive = (torque((this.rpm / this.redline) * REDLINE) * SPORTS.drive) / Math.abs(top);
      if (!controls.up && this.shiftTimer <= 0 && wheelRpm > this.idle) drive = -Math.sign(top) * 6 * (this.rpm / this.redline);
      drive *= Math.sign(top);
    }

    const frontN = 0.48 * cab.mass * GRAVITY;
    const rearN = 0.52 * cab.mass * GRAVITY;
    const front = local(cab, SPORTS.frontAxle);
    const rear = local(cab, SPORTS.rearAxle);
    // Low-slung on track tyres: magic on tarmac, much less so on grass and dirt
    // (and it scrapes along, so soft ground drags it down harder than the rig).
    const sport = (f: SurfaceFeel): SurfaceFeel => (f.grip >= 0.9 ? f : { grip: f.grip * 0.82, drag: f.drag * 3, bump: f.bump });
    const fF = sport(feel(front.x, front.y));
    const fR = sport(feel(rear.x, rear.y));
    this.ground = fR;
    const mu = fR.grip * SPORTS.grip;
    this.bump(cab, v, fF, h);

    // Traction control trims the power to what the rear tyres can take; only a little spin gets through.
    const traction = mu * rearN;
    const spinning = Math.abs(drive) > traction * 1.15;
    const driveForce = clamp(drive, -traction * 0.8, traction * 0.8);
    if (driveForce) longitudinal(cab, rear, cab.angle, driveForce * h, false);

    const brake = controls.down ? SPORTS.brake : 0;
    for (const [p, n, f] of [
      [front, frontN, fF],
      [rear, rearN, fR],
    ] as [Vec, number, SurfaceFeel][]) {
      longitudinal(cab, p, cab.angle, (brake * Math.min(1, f.grip) + SPORTS.rolling + f.drag * (1 + Math.abs(v) / 90)) * n * h, true);
    }
    const speed = Math.hypot(cab.vx, cab.vy);
    if (speed > 0) {
      const f = Math.min(speed * cab.mass, SPORTS.aero * speed * speed * h);
      cab.vx -= (cab.vx / speed) * (f / cab.mass);
      cab.vy -= (cab.vy / speed) * (f / cab.mass);
    }

    // Quick rack, and the front keeps biting at speed. The rear lets go under power.
    const steerAngle = this.steer * 0.55 * (1 - Math.min(0.72, Math.abs(v) / 300));
    const rearLong = Math.abs(driveForce) + brake * rearN;
    const rearLat = Math.sqrt(Math.max(0, (mu * rearN) ** 2 - rearLong ** 2));
    this.slip[0] = axleGrip(cab, front.x, front.y, cab.angle + steerAngle, fF.grip * SPORTS.grip * frontN * h);
    this.slip[1] = axleGrip(cab, rear.x, rear.y, cab.angle, rearLat * h) || spinning;
    this.slip[2] = false;
    // Yaw damping keeps it from spinning on its own; a light touch of stability control.
    cab.av *= Math.exp(-(Math.abs(v) > 20 ? 1.2 : 3) * h);
    this.roll = 0;
    integrate(cab, h);
  }

  /** On its side the rig is a sledge: steel scraping the road until it stops. */
  private slideOnSide(h: number): void {
    for (const b of [this.cab, this.trailer]) {
      const v = Math.hypot(b.vx, b.vy);
      if (v > 0) {
        const dv = Math.min(v, 0.7 * GRAVITY * h);
        b.vx -= (b.vx / v) * dv;
        b.vy -= (b.vy / v) * dv;
      }
      b.av *= Math.exp(-2 * h);
    }
    this.rpm += (IDLE_RPM - this.rpm) * Math.min(1, h * 2);
    this.hitchConstraint(h);
    this.jackknifeStop();
    integrate(this.cab, h);
    integrate(this.trailer, h);
  }

  private hitchConstraint(h: number): void {
    const cab = this.cab;
    const trailer = this.trailer;
    for (let it = 0; it < 4; it++) {
      const pc = local(cab, CAB_HITCH);
      const pt = local(trailer, TRAILER_HITCH);
      const ex = pt.x - pc.x;
      const ey = pt.y - pc.y;
      for (const [nx, ny, e] of [
        [1, 0, ex],
        [0, 1, ey],
      ]) {
        const vc = velocityAt(cab, pc.x, pc.y);
        const vt = velocityAt(trailer, pt.x, pt.y);
        const vrel = (vt.x - vc.x) * nx + (vt.y - vc.y) * ny;
        const k = invMassAlong(cab, pc.x, pc.y, nx, ny) + invMassAlong(trailer, pt.x, pt.y, nx, ny);
        const lambda = -(vrel + (e * 0.25) / h) / k;
        applyImpulse(trailer, nx * lambda, ny * lambda, pt.x, pt.y);
        applyImpulse(cab, -nx * lambda, -ny * lambda, pc.x, pc.y);
      }
    }

  }

  private jackknifeStop(): void {
    const cab = this.cab;
    const trailer = this.trailer;
    const fold = angleDiff(cab.angle, trailer.angle);
    if (Math.abs(fold) > JACKKNIFE) {
      const excess = fold - Math.sign(fold) * JACKKNIFE;
      cab.angle -= excess * 0.4;
      trailer.angle += excess * 0.6;
      const w = cab.av - trailer.av;
      if (Math.sign(w) === Math.sign(fold)) {
        const total = cab.inertia + trailer.inertia;
        cab.av -= w * (trailer.inertia / total);
        trailer.av += w * (cab.inertia / total);
      }
    }
  }

  private collideWorld(city: City, hits: WallHit[]): void {
    const parts: [Body, number, number][] = this.dozer
      ? [
          [this.cab, 4.5, 7],
          [this.cab, -4.5, 7],
        ]
      : this.sports
        ? [
            [this.cab, 4, 4.3],
            [this.cab, -4, 4.3],
          ]
        : [
      [this.cab, 3.5, 6.5],
      [this.cab, -3.5, 6.5],
      [this.trailer, 12, 6],
      [this.trailer, 0, 6],
      [this.trailer, -12, 6],
    ];
    for (const [b, d, r] of parts) {
      const p = local(b, d);
      const push = city.resolveCircle(p.x, p.y, r, this.layer);
      if (!push.x && !push.y) continue;
      const len = Math.hypot(push.x, push.y);
      const cx = p.x - (push.x / len) * r;
      const cy = p.y - (push.y / len) * r;
      const material = city.lastMaterial;
      const structure = city.lastStructure;
      const impact = resolveStatic(b, push, cx, cy, material === 'barrier' ? 0.08 : 0.12, material === 'barrier' ? 0.15 : 0.35);
      if (impact > 4) hits.push({ impact, x: cx, y: cy, material, structure });
    }
  }
}