Back to Poly Dogfight
SOURCE / PINNED RELEASE

Made of little things.

Poly Dogfight

Release
b8511ee374e3…
Author-recorded commit
5579434b9d8b…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-44f63e5422b

Archive hash verified: bcadd2430d8ea2bc…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/sim/rockets.ts
/**
 * Heat-seeking rockets. Pure simulation like guns.ts. A rocket locks onto the
 * plane nearest its nose inside a narrow cone, chases it with a limited turn
 * rate (so a hard turn can shake it), and without a lock coasts along the
 * planet at the altitude it was flying, ready to pick up whoever it meets next.
 */
import { Quaternion, Vector3 } from 'three';
import { clearPath } from './aim.ts';
import { HIT_BOXES, segmentBoxEntry } from './hit-boxes.ts';
import { ROCKETS } from './constants.ts';
import { applyHit, underSurface, type DamagePart, type FlightState, type World } from './flight.ts';

export type Rocket = {
  position: Vector3;
  velocity: Vector3;
  owner: FlightState;
  age: number;
  /** The plane it is chasing, or null while coasting. */
  target: FlightState | null;
  /** Distance from the planet's centre it holds while coasting. */
  holdRadius: number;
  /** Seconds until the seeker looks for a new target. */
  seekTimer: number;
};

export type RocketHit = {
  /** Index of the plane hit, in the `planes` array passed to stepRockets. */
  target: number;
  owner: FlightState;
  part: DamagePart;
  /** Impact point in the target's body frame (for scorch holes). */
  local: Vector3;
  world: Vector3;
  destroyed: boolean;
};

export type RocketEvents = {
  /** A rocket left a wing: the plane's index and the wing (±1). */
  launches: { plane: number; side: number }[];
  hits: RocketHit[];
  /** Rockets that struck the ground, sea or an obstacle, or burnt out. */
  explosions: Vector3[];
};

/** Where the rockets hang, under the lower wings (body frame, x mirrored per side). */
export const ROCKET_MOUNT = new Vector3(1.2, -0.55, -0.6);
const SEEK_INTERVAL = 1 / 20;

const tmp = {
  inv: new Quaternion(),
  a: new Vector3(),
  b: new Vector3(),
  rel: new Vector3(),
  dir: new Vector3(),
  want: new Vector3(),
  axis: new Vector3(),
  up: new Vector3(),
  to: new Vector3(),
  hitLocal: new Vector3(),
};

/** Hit boxes grown by the rocket's own size. */
const ROCKET_BOXES = HIT_BOXES.map((box) => ({
  part: box.part,
  min: box.min.map((v) => v - ROCKETS.radius),
  max: box.max.map((v) => v + ROCKETS.radius),
}));

export function createRockets() {
  return { rockets: [] as Rocket[] };
}
export type Rockets = ReturnType<typeof createRockets>;

/**
 * The plane a seeker at `origin` looking along unit `heading` would lock onto:
 * alive, inside `cone`, within range and in plain sight; nearest the nose wins.
 * `exclude` (the launcher, early in a rocket's flight) is never picked.
 */
export function seekerTarget(
  origin: Vector3,
  heading: Vector3,
  planes: readonly FlightState[],
  exclude: FlightState | null,
  world: World,
  cone: number = ROCKETS.seekerAngle,
): FlightState | null {
  let best: FlightState | null = null;
  let bestDot = Math.cos(cone);
  const to = new Vector3();
  for (const plane of planes) {
    if (plane === exclude || !plane.alive) continue;
    to.copy(plane.position).sub(origin);
    const distance = to.length();
    if (distance > ROCKETS.lockRange || distance < 1e-6) continue;
    const dot = to.dot(heading) / distance;
    if (dot <= bestDot) continue;
    if (!clearPath(origin, plane.position, world)) continue;
    best = plane;
    bestDot = dot;
  }
  return best;
}

/** Turn unit vector `dir` toward unit `want` by at most `maxAngle` radians. */
export function turnToward(dir: Vector3, want: Vector3, maxAngle: number): Vector3 {
  const cos = Math.max(-1, Math.min(1, dir.dot(want)));
  const angle = Math.acos(cos);
  if (angle <= maxAngle) return dir.copy(want);
  const axis = tmp.axis.crossVectors(dir, want);
  if (axis.lengthSq() < 1e-12) {
    // Straight away from the goal: turn about any perpendicular.
    axis.set(1, 0, 0).cross(dir);
    if (axis.lengthSq() < 1e-6) axis.set(0, 1, 0).cross(dir);
  }
  return dir.applyAxisAngle(axis.normalize(), maxAngle).normalize();
}

/** Reload, launch on fresh trigger presses, then fly every rocket and resolve hits. */
export function stepRockets(
  state: Rockets,
  planes: readonly FlightState[],
  triggers: readonly boolean[],
  world: World,
  dt: number,
): RocketEvents {
  const events: RocketEvents = { launches: [], hits: [], explosions: [] };
  const { inv, a, b, rel, dir, want, up, to, hitLocal } = tmp;
  const rockets = state.rockets;

  planes.forEach((plane, index) => {
    plane.rocketCooldown = Math.max(0, plane.rocketCooldown - dt);
    if (plane.rockets < ROCKETS.capacity) {
      plane.rocketCharge += dt / ROCKETS.rechargeTime;
      if (plane.rocketCharge >= 1) {
        plane.rockets++;
        plane.rocketCharge = plane.rockets < ROCKETS.capacity ? plane.rocketCharge - 1 : 0;
      }
    } else plane.rocketCharge = 0;

    const trigger = !!triggers[index];
    const pressed = trigger && !plane.rocketHeld;
    plane.rocketHeld = trigger;
    if (!pressed || !plane.alive || plane.rockets <= 0 || plane.rocketCooldown > 0) return;
    plane.rockets--;
    plane.rocketCooldown = ROCKETS.launchCooldown;
    const side = plane.rocketSide;
    plane.rocketSide = -side;
    const forward = new Vector3(0, 0, -1).applyQuaternion(plane.orientation);
    const position = new Vector3(ROCKET_MOUNT.x * side, ROCKET_MOUNT.y, ROCKET_MOUNT.z)
      .applyQuaternion(plane.orientation)
      .add(plane.position);
    // Leave along the nose, whatever the plane's drift.
    const speed = Math.max(20, plane.velocity.dot(forward) + ROCKETS.launchKick);
    if (rockets.length >= ROCKETS.maxInFlight) events.explosions.push(rockets.shift()!.position);
    rockets.push({
      position,
      velocity: forward.multiplyScalar(speed),
      owner: plane,
      age: 0,
      target: null,
      holdRadius: position.length(),
      seekTimer: 0,
    });
    events.launches.push({ plane: index, side });
  });

  for (let i = rockets.length - 1; i >= 0; i--) {
    const rocket = rockets[i]!;
    rocket.age += dt;
    const ownerOff = rocket.age < ROCKETS.ownerGrace;
    let speed = rocket.velocity.length();
    dir.copy(rocket.velocity).divideScalar(speed);

    // --- Seeker ----------------------------------------------------------------
    const target = rocket.target;
    if (target) {
      to.copy(target.position).sub(rocket.position);
      const distance = to.length();
      const keep =
        target.alive &&
        !(ownerOff && target === rocket.owner) &&
        distance < ROCKETS.lockRange * 1.3 &&
        to.dot(dir) > Math.cos(ROCKETS.trackAngle) * distance;
      if (!keep) {
        rocket.target = null;
        rocket.holdRadius = rocket.position.length();
        rocket.seekTimer = SEEK_INTERVAL;
      }
    } else {
      rocket.seekTimer -= dt;
      if (rocket.seekTimer <= 0) {
        rocket.seekTimer = SEEK_INTERVAL;
        rocket.target = seekerTarget(rocket.position, dir, planes, ownerOff ? rocket.owner : null, world);
      }
    }

    // --- Steering: pure pursuit when locked, follow the planet when coasting ---
    if (rocket.target) {
      want.copy(rocket.target.position).sub(rocket.position).normalize();
    } else {
      up.copy(rocket.position).normalize();
      want.copy(dir).addScaledVector(up, -dir.dot(up));
      if (want.lengthSq() < 1e-8) want.set(1, 0, 0).addScaledVector(up, -up.x);
      want.normalize();
      const climb = Math.max(-0.3, Math.min(0.3, (rocket.holdRadius - rocket.position.length()) / 40));
      want.addScaledVector(up, climb).normalize();
    }
    turnToward(dir, want, ROCKETS.turnRate * dt);
    speed = Math.min(ROCKETS.speed, speed + ROCKETS.accel * dt);
    rocket.velocity.copy(dir).multiplyScalar(speed);

    // --- Planes: sweep in each plane's frame, first contact wins ---------------
    let best = Infinity;
    let bestPart: DamagePart | null = null;
    let bestTarget = -1;
    planes.forEach((plane, index) => {
      if (!plane.alive || (ownerOff && plane === rocket.owner)) return;
      rel.copy(rocket.velocity).sub(plane.velocity).multiplyScalar(dt);
      if (rocket.position.distanceToSquared(plane.position) > (rel.length() + 7) ** 2) return;
      inv.copy(plane.orientation).invert();
      a.copy(rocket.position).sub(plane.position).applyQuaternion(inv);
      b.copy(rocket.position).add(rel).sub(plane.position).applyQuaternion(inv);
      for (const box of ROCKET_BOXES) {
        const t = segmentBoxEntry(a, b, box.min, box.max);
        if (t === null || t >= best) continue;
        best = t;
        bestTarget = index;
        hitLocal.copy(a).lerp(b, t);
        // The armoured nose doesn't stop a rocket: the blast takes the nearest part.
        bestPart = box.part !== 'armor' ? box.part : hitLocal.z > 0 ? 'tail' : hitLocal.x < 0 ? 'left' : 'right';
      }
    });
    if (bestPart) {
      const plane = planes[bestTarget]!;
      const bestLocal = hitLocal.clone();
      const world = bestLocal.clone().applyQuaternion(plane.orientation).add(plane.position);
      const destroyed = applyHit(plane, bestPart, ROCKETS.damage);
      events.hits.push({ target: bestTarget, owner: rocket.owner, part: bestPart, local: bestLocal, world, destroyed });
      rockets.splice(i, 1);
      continue;
    }

    rocket.position.addScaledVector(rocket.velocity, dt);
    if (
      underSurface(world, rocket.position) ||
      world.hitsObstacle(rocket.position, ROCKETS.radius) ||
      rocket.age > ROCKETS.life
    ) {
      events.explosions.push(rocket.position.clone());
      rockets.splice(i, 1);
    }
  }
  return events;
}

/** Rockets locked onto `plane`, for its "incoming" warning. */
export function rocketsChasing(state: Rockets, plane: FlightState): Rocket[] {
  return state.rockets.filter((rocket) => rocket.target === plane);
}