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/guns.ts
/**
 * Fixed forward guns and bullet hits. Pure simulation like flight.ts: bullets
 * are short segments swept each step against hit boxes in each plane's body
 * frame, so fast rounds cannot tunnel through a wing.
 */
import { Quaternion, Vector3 } from 'three';
import { findAim } from './aim.ts';
import { HIT_BOXES, segmentBoxEntry, type Box } from './hit-boxes.ts';
import { GUNS } from './constants.ts';
import { applyHit, underSurface, type DamagePart, type FlightState, type World } from './flight.ts';

export type Bullet = {
  position: Vector3;
  velocity: Vector3;
  /** The plane that fired it; it never hits its own shooter. */
  owner: FlightState;
  age: number;
};

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

export type GunEvents = {
  /** Indices of planes that fired a round this step. */
  shots: number[];
  hits: BulletHit[];
  /** Rounds that struck the ground, sea or an obstacle. */
  impacts: Vector3[];
  confirmations: { owner: FlightState; armored: boolean }[];
};

export { HIT_BOXES, segmentBoxEntry } from './hit-boxes.ts';
const MUZZLE = new Vector3(0.3, 0.55, -2.0);

const tmp = {
  inv: new Quaternion(),
  a: new Vector3(),
  b: new Vector3(),
  rel: new Vector3(),
  muzzle: new Vector3(),
  forward: new Vector3(),
  hitLocal: new Vector3(),
};

export function createGuns() {
  return { bullets: [] as Bullet[] };
}
export type Guns = ReturnType<typeof createGuns>;

/** Fire for every plane whose trigger is held, then move bullets and resolve hits. */
export function stepGuns(guns: Guns, planes: readonly FlightState[], triggers: readonly boolean[], world: World, dt: number): GunEvents {
  const events: GunEvents = { shots: [], hits: [], impacts: [], confirmations: [] };
  const { inv, a, b, rel, muzzle, forward, hitLocal } = tmp;

  planes.forEach((plane, index) => {
    plane.gunCooldown = Math.max(0, plane.gunCooldown - dt);
    if (!plane.alive || !triggers[index] || plane.gunCooldown > 0) return;
    plane.gunCooldown += GUNS.interval;
    plane.gunSide = -plane.gunSide;
    muzzle.set(MUZZLE.x * plane.gunSide, MUZZLE.y, MUZZLE.z).applyQuaternion(plane.orientation).add(plane.position);
    forward.set(0, 0, -1).applyQuaternion(plane.orientation);
    const aim = findAim(plane, planes, world, muzzle);
    if (aim) forward.copy(aim.direction);
    guns.bullets.push({
      position: muzzle.clone(),
      velocity: plane.velocity.clone().addScaledVector(forward, GUNS.muzzleSpeed),
      owner: plane,
      age: 0,
    });
    events.shots.push(index);
  });

  const bullets = guns.bullets;
  for (let i = bullets.length - 1; i >= 0; i--) {
    const bullet = bullets[i]!;
    bullet.age += dt;
    let best = Infinity;
    let bestBox: Box | null = null;
    let bestTarget = -1;
    planes.forEach((plane, index) => {
      if (plane === bullet.owner || !plane.alive) return;
      // Sweep in the target's frame using the relative velocity.
      rel.copy(bullet.velocity).sub(plane.velocity).multiplyScalar(dt);
      if (bullet.position.distanceToSquared(plane.position) > (rel.length() + 6) ** 2) return;
      inv.copy(plane.orientation).invert();
      a.copy(bullet.position).sub(plane.position).applyQuaternion(inv);
      b.copy(bullet.position).add(rel).sub(plane.position).applyQuaternion(inv);
      for (const box of HIT_BOXES) {
        const t = segmentBoxEntry(a, b, box.min, box.max);
        if (t === null || t >= best) continue;
        best = t;
        bestBox = box;
        bestTarget = index;
        hitLocal.copy(a).lerp(b, t);
      }
    });
    if (bestBox) {
      const target = planes[bestTarget]!;
      const bestLocal = hitLocal.clone();
      const world = bestLocal.clone().applyQuaternion(target.orientation).add(target.position);
      const part = (bestBox as Box).part;
      events.confirmations.push({ owner: bullet.owner, armored: part === 'armor' });
      if (part === 'armor') events.impacts.push(world);
      else {
        const destroyed = applyHit(target, part);
        events.hits.push({ owner: bullet.owner, target: bestTarget, part, local: bestLocal, world, destroyed });
      }
      bullets.splice(i, 1);
      continue;
    }

    bullet.position.addScaledVector(bullet.velocity, dt);
    if (underSurface(world, bullet.position) || world.hitsObstacle(bullet.position, 0.2)) {
      events.impacts.push(bullet.position.clone());
      bullets.splice(i, 1);
    } else if (bullet.age > GUNS.life) {
      bullets.splice(i, 1);
    }
  }
  return events;
}