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/raider.ts
import { A10Flight, FLIGHT_ALT, METERS } from './a10.js';
import { Airstrike, type Launch } from './airstrike.js';
import { MAP_H, MAP_W } from './city.js';
import { angleDiff, type Vec } from './geometry.js';
import type { Body } from './physics.js';

/**
 * An A-10 flown by the computer, raiding the city while the player drives: it hunts
 * round wherever the player is (so there's something to see), going for the city's
 * infrastructure first, then buildings, and strafing the traffic in between. It flies
 * the same flight model and weapons as the player's jet: it lines up bomb runs on its
 * own pipper, swings wide when a target's inside its turn, ripples Mk 82s, drops the
 * JDAM on the big ones, walks the GAU-8 across the cars, and comes round again. The
 * city's air defences shoot at it; enough hits and it goes down, and after a while
 * another one comes in.
 */

export interface RaiderTarget {
  x: number;
  y: number;
  /** How it means to hit it. */
  how: 'bomb' | 'jdam' | 'gun' | 'maverick';
  body?: Body;
}

export interface RaiderWorld {
  /** Where the player is: the raid stays round here. */
  focus(): Vec;
  /** Something worth hitting near a point, or null. */
  pickTarget(near: Vec): RaiderTarget | null;
  /** Vehicles on the road near a point (for gun runs). */
  carsNear(x: number, y: number, r: number): Body[];
  /** The jet's been hit hard enough: it's going down. */
  shotDown(x: number, y: number): void;
  toast(text: string): void;
}

export class Raider {
  readonly flight: A10Flight;
  readonly air = new Airstrike();
  x: number;
  y: number;
  damage = 0;
  /** Going down: 0 flying .. 1 in the ground. */
  fall = -1;
  private target: RaiderTarget | null = null;
  private retarget = 0;
  /** Extending away from a target that was inside its turn. */
  private extend = 0;
  private burst = 0;
  private rearm = 0;
  firing = false;
  private time = 0;

  constructor(private readonly world: RaiderWorld) {
    // Comes in from the map edge nearest the action.
    const f = world.focus();
    const edge = [
      { x: f.x, y: 150 },
      { x: f.x, y: MAP_H - 150 },
      { x: 150, y: f.y },
      { x: MAP_W - 150, y: f.y },
    ].sort((a, b) => Math.hypot(a.x - f.x, a.y - f.y) - Math.hypot(b.x - f.x, b.y - f.y))[0];
    this.x = edge.x;
    this.y = edge.y;
    this.flight = new A10Flight(Math.atan2(f.y - edge.y, f.x - edge.x));
    this.flight.lever = 0.45;
    this.flight.spool = 0.45;
  }

  get alive(): boolean {
    return this.fall < 0;
  }

  get altitude(): number {
    return FLIGHT_ALT * (this.fall < 0 ? 1 : 1 - this.fall);
  }

  get vx(): number {
    return (Math.cos(this.flight.heading) * this.flight.v) / METERS;
  }

  get vy(): number {
    return (Math.sin(this.flight.heading) * this.flight.v) / METERS;
  }

  launch(): Launch {
    return { x: this.x, y: this.y, z: this.altitude, vx: this.vx, vy: this.vy, heading: this.flight.heading };
  }

  hit(damage: number): void {
    if (!this.alive) return;
    this.damage = Math.min(100, this.damage + damage);
    if (this.damage >= 100) {
      this.fall = 0;
      this.world.toast('The A-10 is hit and going down!');
    }
  }

  update(dt: number): void {
    this.time += dt;
    this.air.update(dt);
    this.runPending(dt);
    const f = this.flight;
    f.stores = this.air.storesKg;
    this.firing = false;
    if (!this.alive) {
      // Rolled over and falling out of the sky.
      this.fall = Math.min(1, this.fall + dt / 2.6);
      f.bank = Math.min(1.4, f.bank + dt * 1.4);
      f.step(dt, { roll: 1, throttleUp: false, throttleDown: true });
      this.move(dt);
      if (this.fall >= 1) {
        this.world.shotDown(this.x, this.y);
        this.fall = 2;
      }
      return;
    }

    // Out of everything: head away, re-arm, come back.
    if (this.air.winchester || this.rearm > 0) {
      if (this.rearm <= 0) this.rearm = 9;
      this.rearm -= dt;
      if (this.rearm <= 0) this.air.rearm();
    }

    this.retarget -= dt;
    const focus = this.world.focus();
    if (!this.target || this.retarget <= 0) {
      this.target = this.rearm > 0 ? null : this.world.pickTarget(focus);
      this.retarget = 14;
      if (this.target) this.air.mode = this.target.how === 'jdam' ? 'gbu31' : this.target.how === 'bomb' ? (Math.random() < 0.5 ? 'mk82ld' : 'mk82hd') : this.air.mode;
    }
    const t = this.target;
    const goal = t ? (t.body ? { x: t.body.x, y: t.body.y } : t) : { x: focus.x + Math.cos(this.time * 0.2) * 900, y: focus.y + Math.sin(this.time * 0.2) * 900 };

    // Steer: aim the bomb pipper (or the nose) at the target, or extend away to come round.
    const from = this.launch();
    let aimAt: Vec = goal;
    if (t && (t.how === 'bomb' || t.how === 'jdam')) {
      const ccip = this.air.ccipBomb(from);
      // Lead the turn so the pipper, not the nose, comes onto it.
      aimAt = { x: goal.x - (ccip.x - this.x), y: goal.y - (ccip.y - this.y) };
    }
    const want = Math.atan2(aimAt.y - this.y, aimAt.x - this.x);
    const off = angleDiff(want, f.heading);
    const dist = Math.hypot(goal.x - this.x, goal.y - this.y);
    if (this.extend > 0) this.extend -= dt;
    else if (dist < 650 && Math.abs(off) > 1.1) this.extend = 2.2;
    const roll = this.extend > 0 ? 0 : Math.max(-1, Math.min(1, off * 2.2));
    // Slow down for the attack, open up when far off or leaving.
    const throttle = dist > 1800 ? 0.85 : 0.4;
    f.step(dt, { roll, throttleUp: f.lever < throttle, throttleDown: f.lever > throttle + 0.05 });
    this.move(dt);
    // Back over the map if it's wandered off the edge.
    if (this.x < 150 || this.y < 150 || this.x > MAP_W - 150 || this.y > MAP_H - 150) {
      f.heading += Math.max(-0.9 * dt, Math.min(0.9 * dt, angleDiff(Math.atan2(MAP_H / 2 - this.y, MAP_W / 2 - this.x), f.heading)));
    }

    if (!t || this.rearm > 0) {
      this.air.cease();
      return;
    }
    // Release: the pipper's on the target.
    if (t.how === 'bomb' || t.how === 'jdam') {
      const ccip = this.air.ccipBomb(this.launch());
      if (Math.hypot(ccip.x - goal.x, ccip.y - goal.y) < (t.how === 'jdam' ? 70 : 40) && this.air.stock() > 0) {
        const drops = t.how === 'jdam' ? 1 : 2 + Math.floor(Math.random() * 3);
        for (let i = 0; i < drops; i++) setTimeoutLike(this, i * 0.13, () => this.air.drop(this.launch(), t.how === 'jdam' ? ccip : null));
        this.target = null;
      }
    } else if (t.how === 'maverick') {
      if (Math.abs(off) < 0.2 && dist < 2200 && this.air.mavericks > 0 && t.body) {
        this.air.launchMaverick(this.launch(), [t.body]);
        this.target = null;
      }
    }
    // Strafe anything that drifts under the gun cross; on a gun target, hold it there.
    const cross = this.air.ccipGun(this.launch());
    const cars = this.world.carsNear(cross.x, cross.y, 30);
    if (cars.length || (t.how === 'gun' && Math.hypot(cross.x - goal.x, cross.y - goal.y) < 40)) this.burst = Math.max(this.burst, 0.7);
    if (this.burst > 0 && this.air.rounds > 0) {
      this.burst -= dt;
      this.firing = this.air.fire(dt, this.launch()) > 0;
      if (t.how === 'gun' && this.burst <= 0) this.target = null;
    } else this.air.cease();
  }

  private move(dt: number): void {
    const gs = this.flight.v / METERS;
    this.x += Math.cos(this.flight.heading) * gs * dt;
    this.y += Math.sin(this.flight.heading) * gs * dt;
  }

  /** Bombs rippled off a few hundredths of a second apart. */
  pending: { at: number; run: () => void }[] = [];

  private runPending(dt: number): void {
    for (const p of this.pending) p.at -= dt;
    const due = this.pending.filter((p) => p.at <= 0);
    this.pending = this.pending.filter((p) => p.at > 0);
    for (const p of due) p.run();
  }
}

function setTimeoutLike(r: Raider, delay: number, run: () => void): void {
  if (delay <= 0) run();
  else r.pending.push({ at: delay, run });
}