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/missiles.ts
import type { City } from './city.js';
import type { Effects } from './effects.js';
import { angleDiff, type Vec } from './geometry.js';
import type { Body } from './physics.js';
import { inView, type View } from './traffic.js';

interface Missile {
  x: number;
  y: number;
  angle: number;
  speed: number;
  layer: number;
  life: number;
  /** What it's homing on, if anything was in its sights at launch. */
  target: Body | null;
  /** Fired from the air: it flies over walls for its first moments as it dives. */
  overfly: number;
  trail: number;
}

/** Something a missile can hit: a car, a wreck or a service unit. */
export interface MissileTarget {
  body: Body;
  layer: number;
}

const LAUNCH = 230;
const TOP = 380;
const LIFE = 2.2;
const TURN = 2.6;

/**
 * The sports car's missiles: launched from the nose, they lock on to whatever is in a
 * narrow cone ahead, home in a little, and blow up on contact with a vehicle or a wall
 * (or when the motor burns out).
 */
export class Missiles {
  private list: Missile[] = [];
  private cooldown = 0;
  /** Boom: the game handles the blast (damage, shockwave, sound). */
  onExplode: ((x: number, y: number, layer: number) => void) | null = null;

  constructor(
    private readonly city: City,
    private readonly effects: Effects,
  ) {}

  clear(): void {
    this.list = [];
    this.cooldown = 0;
  }

  get ready(): boolean {
    return this.cooldown <= 0;
  }

  /** Fire from a launcher at (x, y) heading `angle`, inheriting the car's speed. Returns false while reloading. */
  fire(x: number, y: number, angle: number, carSpeed: number, layer: number, targets: MissileTarget[], air = false): boolean {
    if (this.cooldown > 0) return false;
    this.cooldown = air ? 0.35 : 0.55;
    // Lock on to the nearest thing in a 20° cone within range.
    let target: Body | null = null;
    let best = air ? 480 : 320;
    for (const t of targets) {
      if (t.layer !== layer) continue;
      const dx = t.body.x - x;
      const dy = t.body.y - y;
      const d = Math.hypot(dx, dy);
      if (d < 8 || d > best || Math.abs(angleDiff(Math.atan2(dy, dx), angle)) > 0.35) continue;
      best = d;
      target = t.body;
    }
    this.list.push({ x, y, angle, speed: Math.max(0, carSpeed) + LAUNCH, layer, life: LIFE, target, trail: 0, overfly: air ? (target ? Infinity : 0.6) : 0 });
    for (let i = 0; i < 4; i++) this.effects.puff(x - Math.cos(angle) * 3, y - Math.sin(angle) * 3, false, true, 0.8);
    this.effects.puff(x - Math.cos(angle) * 4, y - Math.sin(angle) * 4, true, false, 1.4);
    return true;
  }

  update(dt: number, targets: MissileTarget[], view: View): void {
    this.cooldown -= dt;
    for (const m of this.list) {
      m.life -= dt;
      m.overfly -= dt;
      // A locked air-to-ground missile dives straight onto its target.
      if (m.overfly === Infinity && m.target && Math.hypot(m.target.x - m.x, m.target.y - m.y) < 30) m.overfly = 0;
      m.speed = Math.min(TOP, m.speed + 300 * dt);
      if (m.target) {
        const want = Math.atan2(m.target.y - m.y, m.target.x - m.x);
        const d = angleDiff(want, m.angle);
        m.angle += Math.max(-TURN * dt, Math.min(TURN * dt, d));
      }
      // Move in small steps so it can't tunnel through a car or a thin wall.
      const steps = Math.max(1, Math.ceil((m.speed * dt) / 3));
      for (let i = 0; i < steps && m.life > 0; i++) {
        m.x += (Math.cos(m.angle) * m.speed * dt) / steps;
        m.y += (Math.sin(m.angle) * m.speed * dt) / steps;
        if (this.hits(m, targets)) m.life = 0;
      }
      // Smoke trail.
      m.trail -= dt;
      if (m.trail <= 0 && inView(m.x, m.y, view, 60)) {
        m.trail = 0.025;
        this.effects.puff(m.x - Math.cos(m.angle) * 2.5, m.y - Math.sin(m.angle) * 2.5, false, false, 0.7);
      }
    }
    for (const m of this.list) if (m.life <= 0) this.onExplode?.(m.x, m.y, m.layer);
    this.list = this.list.filter((m) => m.life > 0);
  }

  private hits(m: Missile, targets: MissileTarget[]): boolean {
    if (m.overfly <= 0) {
      const wall = this.city.resolveCircle(m.x, m.y, 1.2, m.layer);
      if (wall.x || wall.y) return true;
    }
    for (const t of targets) {
      const b = t.body;
      if (t.layer !== m.layer || Math.abs(b.x - m.x) > b.halfL + 2 || Math.abs(b.y - m.y) > b.halfL + 2) continue;
      const dx = m.x - b.x;
      const dy = m.y - b.y;
      const c = Math.cos(b.angle);
      const s = Math.sin(b.angle);
      if (Math.abs(dx * c + dy * s) <= b.halfL + 1 && Math.abs(-dx * s + dy * c) <= b.halfW + 1) return true;
    }
    return false;
  }

  /** Missiles in flight: a dark body, a red nose and a rocket flame. */
  draw(ctx: CanvasRenderingContext2D, view: View, time: number, layer: number): void {
    for (const m of this.list) {
      if (m.layer !== layer || !inView(m.x, m.y, view, 20)) continue;
      ctx.save();
      ctx.translate(m.x, m.y);
      ctx.rotate(m.angle);
      const flicker = 2.5 + Math.sin(time * 60) * 0.8;
      ctx.fillStyle = 'rgba(255, 200, 60, 0.9)';
      ctx.beginPath();
      ctx.moveTo(-2.2, -0.8);
      ctx.lineTo(-2.2 - flicker, 0);
      ctx.lineTo(-2.2, 0.8);
      ctx.fill();
      ctx.fillStyle = '#2b2b2e';
      ctx.fillRect(-2.4, -0.7, 4.2, 1.4);
      ctx.fillStyle = '#d93a3a';
      ctx.beginPath();
      ctx.moveTo(1.8, -0.7);
      ctx.lineTo(3, 0);
      ctx.lineTo(1.8, 0.7);
      ctx.fill();
      ctx.fillStyle = '#8d9096';
      ctx.fillRect(-2.4, -1.3, 1, 2.6);
      ctx.restore();
    }
  }

  /** Rocket flames for the night overlay. */
  lights(view: View): Vec[] {
    return this.list.filter((m) => inView(m.x, m.y, view, 30));
  }
}