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/hud.ts
import type { BombKind } from './airstrike.js';

/** Everything the A-10's head-up display shows, in screen pixels where it's a position. */
export interface FlightHud {
  speedKmh: number;
  heading: number;
  g: number;
  lever: number;
  spool: number;
  jet: { x: number; y: number } | null;
  bomb: { at: { x: number; y: number } | null; t: number } | null;
  gun: { x: number; y: number } | null;
  mode: BombKind;
  rounds: number;
  mk82: number;
  gbu31: number;
  mavericks: number;
  rearming: number;
  firing: boolean;
  /** A missile is tracking the jet; flares left. */
  missile: boolean;
  flares: number;
  time: number;
}

/** The city's state, for the panel in the corner. */
export interface CityStatus {
  alert: string;
  level: number;
  power: number;
  water: number;
  comms: [number, number];
  hospitals: [number, number];
  engines: [number, number];
  fires: number;
  evacuated: number;
  dead: number;
  trapped: number;
  rescued: number;
}

const GREEN = '#8dff9e';
const AMBER = '#ffc44d';
const RED = '#ff4d5e';
const INK = 'rgba(4, 20, 8, 0.85)';

/**
 * The A-10's head-up display, drawn over the world in screen space: airspeed and g, a
 * heading tape, the throttle, the stores, the bomb pipper (where a bomb released now
 * would land, with its fall line) and the gun cross (where the stream is striking).
 * HUD green with a dark outline so it reads over any ground; on a small frame it keeps
 * just the numbers.
 */
export function drawFlightHud(ctx: CanvasRenderingContext2D, hud: FlightHud, w: number, h: number, dpr: number, top: number, bottom: number): void {
  const min = Math.min(w, h);
  const fs = Math.round(Math.max(10 * dpr, Math.min(15 * dpr, min * 0.026)));
  const compact = min / dpr < 330 || h - top - bottom < fs * 16;
  const mono = `${fs}px ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace`;
  const bold = `700 ${fs}px ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace`;
  ctx.save();
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.lineJoin = 'round';
  ctx.textBaseline = 'middle';
  const lw = Math.max(1, dpr * 1.2);

  const text = (s: string, x: number, y: number, color = GREEN, align: CanvasTextAlign = 'left', font = mono) => {
    ctx.font = font;
    ctx.textAlign = align;
    ctx.lineWidth = lw * 3;
    ctx.strokeStyle = INK;
    ctx.strokeText(s, x, y);
    ctx.fillStyle = color;
    ctx.fillText(s, x, y);
  };
  const stroke = (color: string, path: () => void, width = lw) => {
    ctx.beginPath();
    path();
    ctx.lineWidth = width + lw * 2;
    ctx.strokeStyle = INK;
    ctx.stroke();
    ctx.lineWidth = width;
    ctx.strokeStyle = color;
    ctx.stroke();
  };
  const onScreen = (p: { x: number; y: number }) => p.x > 0 && p.y > top && p.x < w && p.y < h - bottom;

  // Bomb pipper and its fall line from the jet.
  if (hud.bomb?.at && onScreen(hud.bomb.at)) {
    const p = hud.bomb.at;
    const r = fs * 1.1;
    if (hud.jet) {
      ctx.setLineDash([fs * 0.5, fs * 0.4]);
      stroke('rgba(141, 255, 158, 0.75)', () => {
        ctx.moveTo(hud.jet!.x, hud.jet!.y);
        ctx.lineTo(p.x, p.y);
      });
      ctx.setLineDash([]);
    }
    stroke(GREEN, () => ctx.arc(p.x, p.y, r, 0, Math.PI * 2));
    ctx.fillStyle = GREEN;
    ctx.beginPath();
    ctx.arc(p.x, p.y, lw * 1.6, 0, Math.PI * 2);
    ctx.fill();
    if (!compact) text(hud.mode === 'gbu31' ? 'GBU-31' : hud.mode === 'mk82hd' ? 'MK-82 HD' : 'MK-82 LD', p.x + r + fs * 0.4, p.y, GREEN);
  }
  // Gun cross where the stream strikes.
  if (hud.gun && onScreen(hud.gun)) {
    const p = hud.gun;
    const r = fs * 0.9;
    const color = hud.firing ? AMBER : GREEN;
    stroke(color, () => {
      ctx.moveTo(p.x - r, p.y);
      ctx.lineTo(p.x - r * 0.35, p.y);
      ctx.moveTo(p.x + r * 0.35, p.y);
      ctx.lineTo(p.x + r, p.y);
      ctx.moveTo(p.x, p.y - r);
      ctx.lineTo(p.x, p.y - r * 0.35);
      ctx.moveTo(p.x, p.y + r * 0.35);
      ctx.lineTo(p.x, p.y + r);
    });
  }

  const midY = top + (h - top - bottom) / 2;
  if (!compact) {
    // Airspeed box, g and Mach on the left.
    const lx = Math.max(fs * 3.2, w * 0.07);
    const bw = fs * 4.4;
    ctx.fillStyle = INK;
    ctx.fillRect(lx - bw / 2, midY - fs * 0.8, bw, fs * 1.6);
    ctx.strokeStyle = GREEN;
    ctx.lineWidth = lw;
    ctx.strokeRect(lx - bw / 2, midY - fs * 0.8, bw, fs * 1.6);
    text(String(Math.round(hud.speedKmh)), lx, midY, GREEN, 'center', bold);
    text('KM/H', lx, midY - fs * 1.6, GREEN, 'center');
    text(`G ${hud.g.toFixed(1)}`, lx, midY + fs * 1.7, hud.g > 3 ? AMBER : GREEN, 'center');
    text(`M ${(hud.speedKmh / 1225).toFixed(2)}`, lx, midY + fs * 2.9, GREEN, 'center');

    // Heading tape across the top.
    const hy = top + fs * 1.6;
    const hw = Math.min(w * 0.5, fs * 26);
    const hdg = ((((hud.heading * 180) / Math.PI + 90) % 360) + 360) % 360;
    ctx.save();
    ctx.beginPath();
    ctx.rect(w / 2 - hw / 2, hy - fs, hw, fs * 2.4);
    ctx.clip();
    const per = hw / 60;
    for (let d = Math.floor((hdg - 32) / 5) * 5; d <= hdg + 32; d += 5) {
      const x = w / 2 + (d - hdg) * per;
      const n = ((d % 360) + 360) % 360;
      const major = n % 10 === 0;
      stroke(GREEN, () => {
        ctx.moveTo(x, hy + fs * 0.9);
        ctx.lineTo(x, hy + fs * (major ? 0.3 : 0.6));
      });
      if (n % 30 === 0) text(({ 0: 'N', 90: 'E', 180: 'S', 270: 'W' } as Record<number, string>)[n] ?? String(n / 10).padStart(2, '0'), x, hy - fs * 0.2, GREEN, 'center');
    }
    ctx.restore();
    stroke(GREEN, () => {
      ctx.moveTo(w / 2, hy + fs * 1.4);
      ctx.lineTo(w / 2 - fs * 0.4, hy + fs * 1.9);
      ctx.lineTo(w / 2 + fs * 0.4, hy + fs * 1.9);
      ctx.closePath();
    });
  } else {
    text(`${Math.round(hud.speedKmh)} KM/H`, fs * 0.6, top + fs * 1.2, GREEN);
  }

  // Throttle: the lever (bar marker) and what the engines are giving (fill).
  const by = h - bottom - fs * 1.2;
  if (!compact) {
    const tx = fs * 0.8;
    const th = fs * 5;
    const ty = by - th;
    ctx.fillStyle = INK;
    ctx.fillRect(tx, ty, fs * 0.9, th);
    ctx.fillStyle = GREEN;
    ctx.fillRect(tx, ty + th * (1 - hud.spool), fs * 0.9, th * hud.spool);
    stroke(GREEN, () => ctx.rect(tx, ty, fs * 0.9, th));
    stroke(AMBER, () => {
      const ly = ty + th * (1 - hud.lever);
      ctx.moveTo(tx - fs * 0.3, ly);
      ctx.lineTo(tx + fs * 1.2, ly);
    }, lw * 2);
    const lev = hud.lever > 0.97 ? 'MAX' : hud.lever < 0.03 ? 'IDLE' : `${Math.round(hud.lever * 100)}%`;
    text(`THR ${lev}`, tx + fs * 1.6, by - fs * 0.6, GREEN);
    text('W/S · N/M', tx + fs * 1.6, by - fs * 1.8, 'rgba(141, 255, 158, 0.7)');
  }
  // Stores: the selected bomb mode marked.
  const lines: [string, string][] = [
    [`GUN ${String(hud.rounds).padStart(4, ' ')}`, hud.rounds ? (hud.firing ? AMBER : GREEN) : RED],
    [`${hud.mode === 'gbu31' ? ' ' : '>'}MK-82 ×${hud.mk82} ${hud.mode === 'mk82ld' ? 'LD' : 'HD'}`, hud.mk82 ? GREEN : RED],
    [`${hud.mode === 'gbu31' ? '>' : ' '}GBU-31 ×${hud.gbu31}`, hud.gbu31 ? GREEN : RED],
    [` AGM-65 ×${hud.mavericks}`, hud.mavericks ? GREEN : RED],
  ];
  lines.push([` FLARES ×${hud.flares} (F)`, hud.flares ? GREEN : RED]);
  if (hud.rearming > 0) lines.push([`REARMING ${Math.ceil(hud.rearming)}s`, AMBER]);
  if (compact) {
    text(lines[0][0], fs * 0.6, by, lines[0][1]);
    text(hud.mode === 'gbu31' ? `GBU ×${hud.gbu31}` : `MK82 ×${hud.mk82}`, w - fs * 0.6, by, GREEN, 'right');
  } else {
    lines.forEach(([s, color], i) => text(s, w - fs * 0.8, by - (lines.length - 1 - i) * fs * 1.25, color, 'right'));
  }
  // Missile warning: flashing, middle of the frame.
  if (hud.missile && Math.floor(hud.time * 5) % 2 === 0) text('MISSILE · FLARES (F)', w / 2, top + (h - top - bottom) * 0.28, RED, 'center', `700 ${fs * 2}px ui-monospace, monospace`);
  ctx.restore();
}

const fmt = (n: number) => (n >= 10000 ? `${Math.round(n / 1000)}K` : n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(Math.round(n)));

/**
 * The city's vital signs in the top-left corner while you're flying: the alert level,
 * power and water, comms, hospitals and fire engines still working, the fires burning,
 * and the human toll.
 */
export function drawCityStatus(ctx: CanvasRenderingContext2D, c: CityStatus, w: number, h: number, dpr: number, top: number, bottom: number): void {
  const min = Math.min(w, h);
  if (min / dpr < 330 || h - top - bottom < 240 * dpr) return;
  const fs = Math.round(Math.max(9 * dpr, Math.min(12 * dpr, min * 0.02)));
  const rows: [string, string, string][] = [
    ['POWER', `${Math.round(c.power * 100)}%`, c.power < 0.5 ? RED : c.power < 0.9 ? AMBER : GREEN],
    ['WATER', `${Math.round(c.water * 100)}%`, c.water < 0.4 ? RED : c.water < 0.8 ? AMBER : GREEN],
    ['COMMS', `${c.comms[0]}/${c.comms[1]}`, c.comms[0] < c.comms[1] ? AMBER : GREEN],
    ['HOSPITALS', `${c.hospitals[0]}/${c.hospitals[1]}`, c.hospitals[0] < c.hospitals[1] ? AMBER : GREEN],
    ['ENGINES', `${c.engines[0]}/${c.engines[1]}`, c.engines[0] === 0 ? RED : GREEN],
    ['FIRES', String(c.fires), c.fires > 40 ? RED : c.fires > 10 ? AMBER : GREEN],
    ['EVACUATED', fmt(c.evacuated), GREEN],
    ['TRAPPED', fmt(c.trapped), c.trapped ? AMBER : GREEN],
    ['RESCUED', fmt(c.rescued), GREEN],
    ['KILLED', fmt(c.dead), c.dead ? RED : GREEN],
  ];
  const x0 = Math.round(8 * dpr);
  const y0 = Math.round(top + 8 * dpr);
  const lh = fs * 1.35;
  const bw = fs * 13;
  const color = c.level >= 3 ? RED : c.level >= 1 ? AMBER : GREEN;
  ctx.save();
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.fillStyle = 'rgba(8, 14, 10, 0.62)';
  ctx.fillRect(x0, y0, bw, lh * (rows.length + 1.6));
  ctx.strokeStyle = color;
  ctx.lineWidth = Math.max(1, dpr);
  ctx.strokeRect(x0, y0, bw, lh * (rows.length + 1.6));
  ctx.textBaseline = 'middle';
  ctx.font = `700 ${fs}px ui-monospace, Menlo, Consolas, monospace`;
  ctx.fillStyle = color;
  ctx.textAlign = 'left';
  ctx.fillText(`CITY: ${c.alert}`, x0 + fs * 0.6, y0 + lh * 0.8);
  ctx.font = `${fs}px ui-monospace, Menlo, Consolas, monospace`;
  rows.forEach(([k, v, col], i) => {
    const y = y0 + lh * (i + 1.9);
    ctx.fillStyle = 'rgba(200, 220, 205, 0.85)';
    ctx.textAlign = 'left';
    ctx.fillText(k, x0 + fs * 0.6, y);
    ctx.fillStyle = col;
    ctx.textAlign = 'right';
    ctx.fillText(v, x0 + bw - fs * 0.6, y);
  });
  ctx.restore();
}