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/helicopters.ts
import type { City } from './city.js';
import { angleDiff, type Vec } from './geometry.js';
import type { ServiceTask } from './services.js';
import { inView, type View } from './traffic.js';
import { drawHelicopter, drawPed, type PedLook } from './vehicles.js';

export type HeliKind = 'medevac' | 'police';

interface Base {
  kind: HeliKind;
  x: number;
  y: number;
  busy: boolean;
  /** Gone with its building. */
  lost: () => boolean;
}

interface Medic {
  x: number;
  y: number;
  angle: number;
  stride: number;
  queue: Vec[];
  timer: number;
}

const CRUISE = 46;
const MEDIC: PedLook = { shirt: '#ff3b3b', pants: '#1f3b6e', skin: '#e0ac69', hair: '#2b1d14', hat: '#f4f4f4' };

export class Helicopter {
  x: number;
  y: number;
  z = 0;
  vx = 0;
  vy = 0;
  angle = 0;
  rotor = 0;
  state: 'up' | 'fly' | 'down' | 'work' | 'circle' | 'home' | 'land' = 'up';
  timer = 0;
  dest: Vec;
  medics: Medic[] = [];

  constructor(
    readonly kind: HeliKind,
    readonly base: Base,
    readonly target: Vec,
    readonly task: ServiceTask | null,
  ) {
    this.x = base.x;
    this.y = base.y;
    this.dest = { ...target };
  }

  get busy(): boolean {
    return this.state !== 'home' && this.state !== 'land';
  }
}

/** Medevac helicopters from hospital helipads, and a police helicopter that circles big incidents. */
export class Helicopters {
  readonly helis: Helicopter[] = [];
  private readonly bases: Base[] = [];
  private time = 0;
  /** Rotor thump near the player. */
  onRotor: ((x: number, y: number) => void) | null = null;
  private thump = 0;

  constructor(private readonly city: City) {
    let police = 0;
    for (const st of city.stations) {
      const lost = () => !!st.structure?.destroyed;
      if (st.kind === 'hospital') this.bases.push({ kind: 'medevac', x: st.x, y: st.y, busy: false, lost });
      // Two police helicopters, on the first two police station roofs.
      if (st.kind === 'police' && police++ < 2) this.bases.push({ kind: 'police', x: st.x, y: st.y, busy: false, lost });
    }
  }

  clear(): void {
    this.helis.length = 0;
    for (const b of this.bases) b.busy = false;
  }

  covering(kind: HeliKind, x: number, y: number, r: number): boolean {
    return this.helis.some((h) => h.kind === kind && h.busy && Math.hypot(h.target.x - x, h.target.y - y) < r);
  }

  available(kind: HeliKind): number {
    return this.bases.filter((b) => b.kind === kind && !b.busy && !b.lost()).length;
  }

  /** Fly the nearest free helicopter of a kind out to a spot. */
  dispatch(kind: HeliKind, x: number, y: number, task: ServiceTask | null): boolean {
    const base = this.bases.filter((b) => b.kind === kind && !b.busy && !b.lost()).sort((a, b) => Math.hypot(a.x - x, a.y - y) - Math.hypot(b.x - x, b.y - y))[0];
    if (!base) return false;
    const h = new Helicopter(kind, base, { x, y }, task);
    h.angle = Math.atan2(y - base.y, x - base.x);
    if (kind === 'medevac') {
      const spot = this.landingSpot(x, y);
      if (!spot) return false;
      h.dest = spot;
    }
    base.busy = true;
    this.helis.push(h);
    return true;
  }

  /** Somewhere clear of buildings and trees near the scene to set down. */
  private landingSpot(x: number, y: number): Vec | null {
    for (let r = 18; r <= 90; r += 12) {
      for (let k = 0; k < 10; k++) {
        const a = (k / 10) * Math.PI * 2 + r;
        const px = x + Math.cos(a) * r;
        const py = y + Math.sin(a) * r;
        const push = this.city.resolveCircle(px, py, 11, 0);
        if (!push.x && !push.y && !this.city.onDeck(px, py)) return { x: px, y: py };
      }
    }
    return null;
  }

  update(dt: number, view: View, listener: Vec): void {
    this.time += dt;
    for (const h of this.helis) {
      h.rotor += dt * (h.z > 0 || h.state === 'up' ? 28 : 6);
      switch (h.state) {
        case 'up':
          h.z = Math.min(CRUISE, h.z + dt * 16);
          // Climbing out: to the scene, or home after a job.
          if (h.z >= CRUISE * 0.6) h.state = h.timer < 0 ? 'home' : 'fly';
          break;
        case 'fly':
          if (this.flyTo(h, h.dest, dt) < 6) {
            if (h.kind === 'police') {
              h.state = 'circle';
              h.timer = 50;
            } else h.state = 'down';
          }
          break;
        case 'down':
          this.hover(h, dt);
          h.z = Math.max(0, h.z - dt * 14);
          if (h.z <= 0) {
            h.state = 'work';
            h.timer = 0;
            const pts = h.task?.points() ?? [];
            h.medics = [0, 1].map((i) => ({ x: h.x + (i ? 6 : -6), y: h.y + 4, angle: 0, stride: 0, queue: pts.filter((_, k) => k % 2 === i), timer: 0 }));
          }
          break;
        case 'work':
          this.hover(h, dt);
          h.timer += dt;
          if (this.medicsWork(h, dt) || h.timer > 70) {
            h.task?.onDone?.();
            h.medics = [];
            h.state = 'up';
            h.dest = { x: h.base.x, y: h.base.y };
            h.timer = -1;
          }
          break;
        case 'circle': {
          h.timer -= dt;
          const a = this.time * 0.45 + h.base.x;
          this.flyTo(h, { x: h.target.x + Math.cos(a) * 55, y: h.target.y + Math.sin(a) * 55 }, dt, 60);
          h.z += (CRUISE + 8 - h.z) * Math.min(1, dt);
          if (h.timer <= 0) {
            h.task?.onDone?.();
            h.state = 'home';
          }
          break;
        }
        case 'home':
          h.z += (CRUISE - h.z) * Math.min(1, dt);
          if (this.flyTo(h, { x: h.base.x, y: h.base.y }, dt) < 4) h.state = 'land';
          break;
        case 'land':
          this.hover(h, dt);
          h.z = Math.max(0, h.z - dt * 14);
          break;
      }
    }
    for (let i = this.helis.length - 1; i >= 0; i--) {
      const h = this.helis[i];
      if (h.state === 'land' && h.z <= 0) {
        h.base.busy = false;
        this.helis.splice(i, 1);
      }
    }
    this.thump -= dt;
    if (this.thump <= 0) {
      this.thump = 0.16;
      const near = this.helis.filter((h) => h.z > 0 || h.state === 'up').sort((a, b) => Math.hypot(a.x - listener.x, a.y - listener.y) - Math.hypot(b.x - listener.x, b.y - listener.y))[0];
      if (near && inView(near.x, near.y, view, 400)) this.onRotor?.(near.x, near.y);
    }
  }

  /** Accelerate toward a point and turn to face the direction of travel. Returns the distance left. */
  private flyTo(h: Helicopter, p: Vec, dt: number, top = 150): number {
    const dx = p.x - h.x;
    const dy = p.y - h.y;
    const d = Math.hypot(dx, dy);
    const want = Math.min(top, d * 0.9);
    const tvx = d > 0.1 ? (dx / d) * want : 0;
    const tvy = d > 0.1 ? (dy / d) * want : 0;
    const k = Math.min(1, dt * 1.2);
    h.vx += (tvx - h.vx) * k;
    h.vy += (tvy - h.vy) * k;
    h.x += h.vx * dt;
    h.y += h.vy * dt;
    if (Math.hypot(h.vx, h.vy) > 8) h.angle += angleDiff(Math.atan2(h.vy, h.vx), h.angle) * Math.min(1, dt * 2);
    return d;
  }

  private hover(h: Helicopter, dt: number): void {
    h.vx *= Math.exp(-3 * dt);
    h.vy *= Math.exp(-3 * dt);
    h.x += h.vx * dt;
    h.y += h.vy * dt;
  }

  /** Flight medics run to each patient and treat them. Returns true once both are back aboard. */
  private medicsWork(h: Helicopter, dt: number): boolean {
    let done = true;
    for (const m of h.medics) {
      const goal = m.queue[0] ?? { x: h.x, y: h.y + 4 };
      const dx = goal.x - m.x;
      const dy = goal.y - m.y;
      const d = Math.hypot(dx, dy);
      if (d > 3) {
        const step = Math.min(d - 2.9, 26 * dt);
        m.x += (dx / d) * step;
        m.y += (dy / d) * step;
        m.angle = Math.atan2(dy, dx);
        m.stride += step;
        done = false;
        continue;
      }
      if (!m.queue.length) continue;
      done = false;
      m.timer += dt;
      if (m.timer >= (h.task?.work ?? 2)) {
        m.timer = 0;
        h.task?.onPointDone?.(goal, null as never);
        m.queue.shift();
      }
    }
    return done;
  }

  /** Helicopters waiting on their pads. */
  drawBases(ctx: CanvasRenderingContext2D, view: View): void {
    for (const b of this.bases) {
      if (b.busy || b.lost() || !inView(b.x, b.y, view, 30)) continue;
      drawHelicopter(ctx, b.x, b.y, -Math.PI / 2, 0, 0.4, b.kind === 'medevac' ? '#f4f1ea' : '#1f3b6e', b.kind === 'medevac' ? '#d93a3a' : '#f4f1ea', false);
    }
  }

  draw(ctx: CanvasRenderingContext2D, view: View, time: number): void {
    for (const h of this.helis) {
      if (!inView(h.x, h.y, view, 60)) continue;
      for (const m of h.medics) drawPed(ctx, MEDIC, m.x, m.y, m.angle, m.stride, 'walk', 0, 0, time);
      const flash = Math.floor(time * 3) % 2 === 0;
      drawHelicopter(ctx, h.x, h.y, h.angle, h.z, h.rotor, h.kind === 'medevac' ? '#f4f1ea' : '#1f3b6e', h.kind === 'medevac' ? '#d93a3a' : '#f4f1ea', flash);
    }
  }

  /** Searchlights (police) and landing lights for the night overlay. */
  beams(view: View): { x: number; y: number; r: number }[] {
    const out: { x: number; y: number; r: number }[] = [];
    for (const h of this.helis) {
      if (h.kind === 'police' && h.state === 'circle') {
        const a = this.time * 0.8;
        const p = { x: h.target.x + Math.cos(a) * 18, y: h.target.y + Math.sin(a * 1.3) * 18 };
        if (inView(p.x, p.y, view, 40)) out.push({ x: p.x, y: p.y, r: 30 });
      } else if (h.z < 30 && h.z > 0 && inView(h.x, h.y, view, 40)) out.push({ x: h.x, y: h.y, r: 24 });
    }
    return out;
  }

  /** Blinking nav lights. */
  lights(view: View): { x: number; y: number; color: string }[] {
    if (Math.floor(this.time * 2) % 2) return [];
    return this.helis.filter((h) => inView(h.x, h.y, view, 40)).map((h) => ({ x: h.x, y: h.y, color: 'rgba(255, 40, 40, 0.8)' }));
  }
}