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/airstrike.ts
import { FLIGHT_ALT } from './a10.js';
import type { Body } from './physics.js';
import { inView, type View } from './traffic.js';

/**
 * The A-10's weapons, flown as projectiles from the jet's working height down to the
 * ground. Positions and velocities are in map units; time is compressed to the game's
 * scale (a bomb takes about 1.3 s to fall), so everything lands where you can see it.
 *
 *  - GAU-8/A Avenger: 1,150 rounds at 3,900 a minute after a half-second spin-up, the
 *    combat mix of five armour-piercing incendiary rounds to one high-explosive, a tight
 *    stream walking across the ground ahead of the nose.
 *  - Mk 82 AIR (500 lb): the BSU-49 ballute pops open in high-drag mode and the bomb
 *    drops steeply behind the jet, or stays closed in low-drag mode and flies on ahead.
 *  - GBU-31 JDAM (2,000 lb Mk 84): steered onto the point under the pipper at release.
 *  - AGM-65 Maverick: locks the vehicle nearest the jet's line of flight and dives onto it.
 */

export type BombKind = 'mk82hd' | 'mk82ld' | 'gbu31';

export const BOMB_NAMES: Record<BombKind, string> = {
  mk82hd: 'MK-82 AIR · HIGH DRAG',
  mk82ld: 'MK-82 AIR · LOW DRAG',
  gbu31: 'GBU-31 JDAM',
};

/** Where a weapon leaves the jet: map position and velocity (units, units/s). */
export interface Launch {
  x: number;
  y: number;
  z: number;
  vx: number;
  vy: number;
  heading: number;
}

/** Projects a point at height z (units) to where it's drawn in ground coordinates, with its scale. */
export type Lift = (x: number, y: number, z: number) => { x: number; y: number; s: number } | null;

interface Bomb {
  kind: BombKind;
  x: number;
  y: number;
  z: number;
  vx: number;
  vy: number;
  vz: number;
  age: number;
  /** The ballute's inflation, 0..1 (high drag only). */
  chute: number;
  target?: { x: number; y: number };
}

interface Round {
  x0: number;
  y0: number;
  x1: number;
  y1: number;
  t: number;
  dur: number;
  hei: boolean;
}

interface Maverick {
  x: number;
  y: number;
  z: number;
  angle: number;
  speed: number;
  age: number;
  target: Body | null;
  aim: { x: number; y: number };
  dist0: number;
}

interface AirPuff {
  x: number;
  y: number;
  z: number;
  vx: number;
  vy: number;
  r: number;
  grow: number;
  life: number;
  max: number;
  rgb: [number, number, number];
  alpha: number;
}

/** Gravity at the game's scale (units/s²): a bomb falls the working height in about 1.3 s. */
const GRAVITY = 140;
const ROUNDS_PER_SECOND = 65;
const ROUND_SPEED = 3000;
/** Spread of the stream (radians, one sigma). */
const SIGMA = 0.012;
export const FULL_LOAD = { rounds: 1150, mk82: 6, gbu31: 1, mavericks: 2 };

const gauss = () => {
  let u = 0;
  let v = 0;
  while (u === 0) u = Math.random();
  while (v === 0) v = Math.random();
  return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
};

/** How far ahead of the jet the gun's rounds strike the ground (units). */
export function gunReach(speed: number): number {
  return 230 + speed * 0.12;
}

export class Airstrike {
  rounds = FULL_LOAD.rounds;
  mk82 = FULL_LOAD.mk82;
  gbu31 = FULL_LOAD.gbu31;
  mavericks = FULL_LOAD.mavericks;
  mode: BombKind = 'mk82hd';
  /** Barrel spin-up, 0..1: the first moments of a burst fire slower. */
  spin = 0;
  private bombs: Bomb[] = [];
  private rnds: Round[] = [];
  private mavs: Maverick[] = [];
  private puffs: AirPuff[] = [];
  private gunTimer = 0;
  private bombCooldown = 0;
  private mavCooldown = 0;
  private mavSide = 1;
  private heiCount = 0;

  /** A bomb reaches the ground. */
  onBomb: ((x: number, y: number, kind: BombKind) => void) | null = null;
  /** A 30 mm round hits the ground (dx, dy: its direction of travel). */
  onRound: ((x: number, y: number, hei: boolean, dx: number, dy: number) => void) | null = null;
  /** A Maverick's warhead goes off. */
  onMaverick: ((x: number, y: number) => void) | null = null;
  /** The ballute pops open. */
  onBallute: ((x: number) => void) | null = null;

  clear(): void {
    this.bombs = [];
    this.rnds = [];
    this.mavs = [];
    this.puffs = [];
    this.spin = 0;
    this.gunTimer = 0;
    this.rearm();
  }

  rearm(): void {
    this.rounds = FULL_LOAD.rounds;
    this.mk82 = FULL_LOAD.mk82;
    this.gbu31 = FULL_LOAD.gbu31;
    this.mavericks = FULL_LOAD.mavericks;
    if (this.mode === 'gbu31' && !this.gbu31) this.mode = 'mk82hd';
  }

  /** Everything still hanging on the jet, plus the ammunition (kg). */
  get storesKg(): number {
    return this.rounds * 0.7 + this.mk82 * 241 + this.gbu31 * 934 + this.mavericks * 305 + 250;
  }

  get winchester(): boolean {
    return !this.rounds && !this.mk82 && !this.gbu31 && !this.mavericks;
  }

  /** Bombs of the selected kind still on the racks. */
  stock(kind: BombKind = this.mode): number {
    return kind === 'gbu31' ? this.gbu31 : this.mk82;
  }

  /** Step through the bomb modes: Mk 82 high drag, low drag, the JDAM. */
  cycleMode(): BombKind {
    const order: BombKind[] = ['mk82hd', 'mk82ld', 'gbu31'];
    for (let i = 1; i <= order.length; i++) {
      const next = order[(order.indexOf(this.mode) + i) % order.length];
      if (this.stock(next) > 0 || i === order.length) {
        this.mode = next;
        break;
      }
    }
    return this.mode;
  }

  /** Hold the trigger: the stream walks across the ground ahead of the nose. Returns rounds fired. */
  fire(dt: number, from: Launch): number {
    if (this.rounds <= 0) {
      this.cease();
      return 0;
    }
    this.spin = Math.min(1, this.spin + dt / 0.5);
    const rate = ROUNDS_PER_SECOND * (0.55 + 0.45 * this.spin);
    this.gunTimer -= dt;
    let fired = 0;
    const c = Math.cos(from.heading);
    const s = Math.sin(from.heading);
    const speed = Math.hypot(from.vx, from.vy);
    while (this.gunTimer <= 0 && this.rounds > 0) {
      this.gunTimer += 1 / rate;
      this.rounds--;
      fired++;
      const reach = gunReach(speed) * (1 + gauss() * SIGMA * 2);
      const side = gauss() * SIGMA * reach;
      const x0 = from.x + c * 22;
      const y0 = from.y + s * 22;
      const x1 = from.x + c * reach - s * side;
      const y1 = from.y + s * reach + c * side;
      this.heiCount = (this.heiCount + 1) % 6;
      this.rnds.push({ x0, y0, x1, y1, t: 0, dur: Math.hypot(reach, FLIGHT_ALT) / ROUND_SPEED, hei: this.heiCount === 0 });
      // Gun gas: the cloud that streams back from the nose.
      if (Math.random() < 0.45) this.puff(x0, y0, FLIGHT_ALT, from.vx * 0.5, from.vy * 0.5, 3, 12, 1, [196, 192, 184], 0.45);
    }
    return fired;
  }

  /** Let go of the trigger: the barrels coast down. */
  cease(): void {
    this.gunTimer = 0;
    this.spin = Math.max(0, this.spin - 0.08);
  }

  /** Release one bomb of the selected kind. Returns what was dropped, if anything. */
  drop(from: Launch, target: { x: number; y: number } | null): BombKind | null {
    if (this.bombCooldown > 0) return null;
    const kind = this.mode;
    if (this.stock(kind) <= 0) return null;
    if (kind === 'gbu31') this.gbu31--;
    else this.mk82--;
    // Racks ripple about 0.12 s apart.
    this.bombCooldown = 0.12;
    const side = this.mk82 % 2 ? -1 : 1;
    const scatter = kind === 'gbu31' ? 0 : 0.03;
    const speed = Math.hypot(from.vx, from.vy);
    this.bombs.push({
      kind,
      x: from.x - Math.sin(from.heading) * side * 6,
      y: from.y + Math.cos(from.heading) * side * 6,
      z: from.z - 3,
      vx: from.vx + gauss() * speed * scatter,
      vy: from.vy + gauss() * speed * scatter,
      vz: -8,
      age: 0,
      chute: 0,
      target: kind === 'gbu31' && target ? { ...target } : undefined,
    });
    if (this.mode !== 'gbu31' && this.mk82 <= 0 && this.gbu31 > 0) this.mode = 'gbu31';
    if (this.mode === 'gbu31' && this.gbu31 <= 0 && this.mk82 > 0) this.mode = 'mk82hd';
    return kind;
  }

  /** Fire a Maverick at the vehicle nearest the line of flight (within 15°), or straight ahead. */
  launchMaverick(from: Launch, targets: Body[]): boolean {
    if (this.mavericks <= 0 || this.mavCooldown > 0) return false;
    let best: Body | null = null;
    let bestCos = Math.cos(0.26);
    const c = Math.cos(from.heading);
    const s = Math.sin(from.heading);
    for (const b of targets) {
      const dx = b.x - from.x;
      const dy = b.y - from.y;
      const d = Math.hypot(dx, dy);
      if (d < 120 || d > 2600) continue;
      const k = (dx * c + dy * s) / d;
      if (k > bestCos) {
        bestCos = k;
        best = b;
      }
    }
    this.mavericks--;
    this.mavCooldown = 0.6;
    this.mavSide = -this.mavSide;
    const aim = { x: from.x + c * 900, y: from.y + s * 900 };
    const tx = best ? best.x : aim.x;
    const ty = best ? best.y : aim.y;
    this.mavs.push({
      x: from.x - s * this.mavSide * 14,
      y: from.y + c * this.mavSide * 14,
      z: from.z - 4,
      angle: from.heading,
      speed: Math.hypot(from.vx, from.vy),
      age: 0,
      target: best,
      aim,
      dist0: Math.max(200, Math.hypot(tx - from.x, ty - from.y)),
    });
    return true;
  }

  /** Where a bomb released now would land, and how long it would fall. */
  ccipBomb(from: Launch, kind: BombKind = this.mode): { x: number; y: number; t: number } {
    let { x, y, z, vx, vy } = from;
    let vz = -8;
    let t = 0;
    const dt = 0.02;
    while (z > 0 && t < 5) {
      const chute = kind === 'mk82hd' ? Math.min(1, t / 0.35) : 0;
      if (chute) {
        const k = Math.exp(-3.2 * chute * dt);
        vx *= k;
        vy *= k;
      }
      vz -= GRAVITY * dt;
      if (chute) vz = Math.max(vz, -150);
      x += vx * dt;
      y += vy * dt;
      z += vz * dt;
      t += dt;
    }
    return { x, y, t };
  }

  /** Where the gun's stream is striking. */
  ccipGun(from: Launch): { x: number; y: number } {
    const reach = gunReach(Math.hypot(from.vx, from.vy));
    return { x: from.x + Math.cos(from.heading) * reach, y: from.y + Math.sin(from.heading) * reach };
  }

  /** Smoke from a damaged jet: grey streaming back, or black and burning when it's bad. */
  smoke(x: number, y: number, z: number, burning: boolean): void {
    if (burning) this.puff(x, y, z, 0, 0, 3, 8, 0.35, [255, 150, 50], 0.85);
    this.puff(x, y, z, 0, 0, 4, burning ? 14 : 9, burning ? 2.4 : 1.6, burning ? [40, 36, 34] : [120, 118, 114], burning ? 0.7 : 0.4);
  }

  private puff(x: number, y: number, z: number, vx: number, vy: number, r: number, grow: number, life: number, rgb: [number, number, number], alpha: number): void {
    if (this.puffs.length > 600) this.puffs.shift();
    this.puffs.push({ x, y, z, vx, vy, r, grow, life, max: life, rgb, alpha });
  }

  update(dt: number): void {
    this.bombCooldown -= dt;
    this.mavCooldown -= dt;

    for (const b of this.bombs) {
      b.age += dt;
      if (b.kind === 'mk82hd') {
        const before = b.chute;
        b.chute = Math.min(1, b.age / 0.35);
        if (before === 0 && b.chute > 0) this.onBallute?.(b.x);
        const k = Math.exp(-3.2 * b.chute * dt);
        b.vx *= k;
        b.vy *= k;
      }
      b.vz -= GRAVITY * dt;
      if (b.kind === 'mk82hd' && b.chute > 0) b.vz = Math.max(b.vz, -150);
      // The JDAM's tail fins steer its fall onto the coordinates it was given.
      if (b.target) {
        const tgo = (b.vz + Math.sqrt(b.vz * b.vz + 2 * GRAVITY * b.z)) / GRAVITY;
        if (tgo > 0.05) {
          const lim = 600 * dt;
          b.vx += Math.max(-lim, Math.min(lim, ((b.target.x - b.x) / tgo - b.vx) * 4 * dt));
          b.vy += Math.max(-lim, Math.min(lim, ((b.target.y - b.y) / tgo - b.vy) * 4 * dt));
        }
      }
      b.x += b.vx * dt;
      b.y += b.vy * dt;
      b.z += b.vz * dt;
    }
    for (const b of this.bombs) if (b.z <= 0) this.onBomb?.(b.x, b.y, b.kind);
    this.bombs = this.bombs.filter((b) => b.z > 0);

    for (const r of this.rnds) r.t += dt;
    for (const r of this.rnds) {
      if (r.t < r.dur) continue;
      const l = Math.hypot(r.x1 - r.x0, r.y1 - r.y0) || 1;
      this.onRound?.(r.x1, r.y1, r.hei, (r.x1 - r.x0) / l, (r.y1 - r.y0) / l);
    }
    this.rnds = this.rnds.filter((r) => r.t < r.dur);

    // Mavericks: off the rail, the motor lights, and it flies down onto the target.
    for (const m of this.mavs) {
      m.age += dt;
      const tx = m.target ? m.target.x : m.aim.x;
      const ty = m.target ? m.target.y : m.aim.y;
      if (m.age > 0.15) {
        m.speed = Math.min(1000, m.speed + (m.age < 1 ? 420 : 80) * dt);
        const want = Math.atan2(ty - m.y, tx - m.x);
        let d = want - m.angle;
        while (d > Math.PI) d -= Math.PI * 2;
        while (d < -Math.PI) d += Math.PI * 2;
        m.angle += Math.max(-3 * dt, Math.min(3 * dt, d));
        if (Math.random() < dt * 60) this.puff(m.x, m.y, m.z, 0, 0, 3.5, 14, 1.8 + Math.random(), [228, 226, 222], 0.5);
      }
      m.x += Math.cos(m.angle) * m.speed * dt;
      m.y += Math.sin(m.angle) * m.speed * dt;
      // Glide down so it arrives on the ground at the target.
      const left = Math.hypot(tx - m.x, ty - m.y);
      m.z = Math.min(m.z, Math.max(0, (FLIGHT_ALT - 4) * Math.min(1, left / m.dist0)));
      if (left < 8) m.z = 0;
    }
    for (const m of this.mavs) if (m.z <= 0 || m.age > 6) this.onMaverick?.(m.x, m.y);
    this.mavs = this.mavs.filter((m) => m.z > 0 && m.age <= 6);

    for (const p of this.puffs) {
      p.life -= dt;
      p.r += p.grow * dt;
      const drag = Math.exp(-dt * 1.6);
      p.vx *= drag;
      p.vy *= drag;
      p.x += p.vx * dt;
      p.y += p.vy * dt;
    }
    this.puffs = this.puffs.filter((p) => p.life > 0);
  }

  /** Bombs on their way down, Mavericks and their smoke, the gun gas and the streaking rounds. */
  draw(ctx: CanvasRenderingContext2D, view: View, lift: Lift, pixel: number, time: number): void {
    for (const p of this.puffs) {
      const at = lift(p.x, p.y, p.z);
      if (!at || !inView(at.x, at.y, view, 60)) continue;
      const k = p.life / p.max;
      ctx.fillStyle = `rgba(${p.rgb[0]}, ${p.rgb[1]}, ${p.rgb[2]}, ${p.alpha * k * k})`;
      ctx.beginPath();
      ctx.arc(at.x, at.y, p.r * at.s, 0, Math.PI * 2);
      ctx.fill();
    }
    for (const b of this.bombs) {
      const at = lift(b.x, b.y, b.z);
      if (!at || !inView(at.x, at.y, view, 60)) continue;
      // Its shadow races to meet it.
      ctx.fillStyle = `rgba(0, 0, 0, ${0.3 * (1 - b.z / FLIGHT_ALT) + 0.08})`;
      ctx.beginPath();
      ctx.ellipse(b.x + b.z * 0.35, b.y + b.z * 0.5, b.kind === 'gbu31' ? 6 : 3.8, 1.5, Math.atan2(b.vy, b.vx), 0, Math.PI * 2);
      ctx.fill();
      const heavy = b.kind === 'gbu31';
      const len = heavy ? 6 : 3.6;
      const wid = heavy ? 0.75 : 0.45;
      ctx.save();
      ctx.translate(at.x, at.y);
      ctx.rotate(Math.atan2(b.vy, b.vx));
      // Nose-down bombs look shorter from above.
      const fall = Math.hypot(b.vx, b.vy) / (Math.hypot(b.vx, b.vy, b.vz) || 1);
      ctx.scale(at.s * (0.4 + 0.6 * fall), at.s);
      if (b.kind === 'mk82hd' && b.chute > 0) {
        // The ballute: a fat khaki bag on a short riser behind the tail.
        const r = wid * 2.4 * b.chute;
        ctx.strokeStyle = 'rgba(40, 40, 40, 0.7)';
        ctx.lineWidth = 0.4;
        ctx.beginPath();
        ctx.moveTo(-len, 0);
        ctx.lineTo(-len - r * 1.6, 0);
        ctx.stroke();
        ctx.fillStyle = '#b9a76a';
        ctx.beginPath();
        ctx.ellipse(-len - r * 2.2, 0, r * 0.8, r, 0, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.fillStyle = heavy ? '#5d6550' : '#4f5a45';
      ctx.beginPath();
      ctx.ellipse(0, 0, len, wid, 0, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = '#2b2b2e';
      ctx.fillRect(-len - wid * 0.4, -wid * 1.8, wid * 1.2, wid * 3.6);
      ctx.fillStyle = '#ffd400';
      ctx.fillRect(len * 0.45, -wid, wid * 0.5, wid * 2);
      ctx.restore();
    }
    for (const m of this.mavs) {
      const at = lift(m.x, m.y, m.z);
      if (!at || !inView(at.x, at.y, view, 60)) continue;
      const len = 3.8;
      ctx.save();
      ctx.translate(at.x, at.y);
      ctx.rotate(m.angle);
      ctx.scale(at.s, at.s);
      if (m.age > 0.15) {
        const flick = len * (0.9 + Math.sin(time * 70) * 0.25);
        ctx.fillStyle = 'rgba(255, 236, 170, 0.95)';
        ctx.beginPath();
        ctx.moveTo(-len, -0.6);
        ctx.lineTo(-len - flick, 0);
        ctx.lineTo(-len, 0.6);
        ctx.fill();
      }
      ctx.fillStyle = '#c9c7bd';
      ctx.fillRect(-len, -0.5, len * 2, 1);
      ctx.fillStyle = '#3a3a3e';
      ctx.beginPath();
      ctx.arc(len, 0, 0.5, -Math.PI / 2, Math.PI / 2);
      ctx.fill();
      ctx.fillStyle = '#8d9096';
      ctx.fillRect(-len, -1.4, 1.1, 2.8);
      ctx.fillRect(len * 0.1, -1.1, 0.8, 2.2);
      ctx.restore();
    }
    // The rounds: hot streaks from the nose down to the ground.
    ctx.globalCompositeOperation = 'lighter';
    ctx.lineCap = 'round';
    for (const r of this.rnds) {
      const k0 = Math.max(0, r.t / r.dur - 0.35);
      const k1 = Math.min(1, r.t / r.dur);
      const a = lift(r.x0 + (r.x1 - r.x0) * k0, r.y0 + (r.y1 - r.y0) * k0, FLIGHT_ALT * (1 - k0));
      const b = lift(r.x0 + (r.x1 - r.x0) * k1, r.y0 + (r.y1 - r.y0) * k1, FLIGHT_ALT * (1 - k1));
      if (!a || !b || !inView(b.x, b.y, view, 80)) continue;
      ctx.strokeStyle = r.hei ? 'rgba(255, 214, 120, 0.9)' : 'rgba(255, 170, 70, 0.6)';
      ctx.lineWidth = (r.hei ? 1.8 : 1.2) * pixel;
      ctx.beginPath();
      ctx.moveTo(a.x, a.y);
      ctx.lineTo(b.x, b.y);
      ctx.stroke();
    }
    ctx.lineCap = 'butt';
    ctx.globalCompositeOperation = 'source-over';
  }

  /** Glows for the night overlay: hot rounds and rocket motors. */
  lights(lift: Lift): { x: number; y: number }[] {
    const out: { x: number; y: number }[] = [];
    for (const r of this.rnds) {
      if (!r.hei) continue;
      const k = Math.min(1, r.t / r.dur);
      const at = lift(r.x0 + (r.x1 - r.x0) * k, r.y0 + (r.y1 - r.y0) * k, FLIGHT_ALT * (1 - k));
      if (at) out.push(at);
    }
    for (const m of this.mavs) {
      const at = lift(m.x, m.y, m.z);
      if (at && m.age > 0.15) out.push(at);
    }
    return out;
  }
}