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