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/aim.ts
/** Shared, shot-time assistance. Bullets remain straight after leaving the muzzle. */
import { Quaternion, Vector3 } from 'three';
import { HIT_BOXES, segmentBoxEntry } from './hit-boxes.ts';
import { GUNS } from './constants.ts';
import { underSurface, type FlightState, type World } from './flight.ts';

export type AimSolution = { target: FlightState; direction: Vector3; point: Vector3; time: number; angle: number };
const points = [new Vector3(-1.9, 0.4, -0.55), new Vector3(1.9, 0.4, -0.55), new Vector3(0, 0.5, 2.45)];

export function gunMuzzle(plane: FlightState, side = -plane.gunSide): Vector3 {
  return new Vector3(0.3 * side, 0.55, -2).applyQuaternion(plane.orientation).add(plane.position);
}

/** Smallest positive intercept time, including the shooter's inherited velocity. */
export function interceptTime(relative: Vector3, velocity: Vector3, speed: number): number | null {
  const a = velocity.lengthSq() - speed * speed;
  const b = 2 * relative.dot(velocity);
  const c = relative.lengthSq();
  if (c < 1e-9) return null;
  if (Math.abs(a) < 1e-9) return b < 0 ? -c / b : null;
  const discriminant = b * b - 4 * a * c;
  if (discriminant < 0) return null;
  const root = Math.sqrt(discriminant);
  const times = [(-b - root) / (2 * a), (-b + root) / (2 * a)].filter(t => t > 0);
  return times.length ? Math.min(...times) : null;
}

/** Sample spacing for line of sight; terrain facets are ~9 m across. */
const PATH_STEP = 2;
const pathPoint = new Vector3();

export function clearPath(from: Vector3, to: Vector3, world: World): boolean {
  const distance = from.distanceTo(to);
  const steps = Math.max(1, Math.ceil(distance / PATH_STEP));
  // Pad the probe by half a step so obstacles between samples still block.
  const probe = 0.7 + (PATH_STEP - 1) / 2;
  for (let i = 0; i <= steps; i++) {
    pathPoint.copy(from).lerp(to, i / steps);
    if (underSurface(world, pathPoint) || world.hitsObstacle(pathPoint, probe)) return false;
  }
  return true;
}

const rayPoint = new Vector3();

/**
 * Distance along a straight ray to the first ground or obstacle contact,
 * or `max` when it stays clear. `direction` must be normalised.
 */
export function castRay(from: Vector3, direction: Vector3, max: number, world: World): number {
  for (let d = PATH_STEP; d < max; d += PATH_STEP) {
    rayPoint.copy(from).addScaledVector(direction, d);
    if (underSurface(world, rayPoint) || world.hitsObstacle(rayPoint, 0.5)) return Math.max(0, d - PATH_STEP / 2);
  }
  return max;
}

export function findAim(plane: FlightState, planes: readonly FlightState[], world: World, muzzle = gunMuzzle(plane)): AimSolution | null {
  if (!plane.alive) return null;
  const forward = new Vector3(0, 0, -1).applyQuaternion(plane.orientation);
  let best: AimSolution | null = null;
  let bestDot = Math.cos(GUNS.aimAssistAngle);
  for (const target of planes) {
    if (target === plane || !target.alive) continue;
    // Reject distant/behind targets before solving and checking terrain.
    const centre = target.position.clone().sub(muzzle);
    const depth = centre.dot(forward);
    if (depth <= 0) continue;
    const relativeVelocity = target.velocity.clone().sub(plane.velocity);
    const speed = relativeVelocity.length();
    if (centre.lengthSq() > ((GUNS.muzzleSpeed + speed) * GUNS.life + 4) ** 2) continue;
    // Generous front cone plus possible transverse travel: no roots/trig solves
    // for planes well off the nose, without discarding valid crossing shots.
    const lateralTravel = relativeVelocity.clone().addScaledVector(forward, -relativeVelocity.dot(forward)).length() * GUNS.life;
    if (centre.lengthSq() - depth * depth > (depth * 0.1 + lateralTravel + 4) ** 2) continue;
    for (const local of points) {
      const origin = local.clone().applyQuaternion(target.orientation).add(target.position);
      const relative = origin.clone().sub(muzzle);
      const velocity = relativeVelocity;
      const time = interceptTime(relative, velocity, GUNS.muzzleSpeed);
      if (time === null || time > GUNS.life) continue;
      const direction = relative.addScaledVector(velocity, time).normalize();
      const dot = forward.dot(direction);
      if (dot < bestDot || (best && Math.abs(dot - bestDot) < 1e-12 && time >= best.time)) continue;
      // Reject aim points hidden behind another hit zone (especially front armor).
      const inverse = new Quaternion().copy(target.orientation).invert();
      const start = muzzle.clone().sub(target.position).applyQuaternion(inverse);
      let first = Infinity;
      let armored = false;
      for (const box of HIT_BOXES) {
        const entry = segmentBoxEntry(start, local, box.min, box.max);
        if (entry !== null && entry < first) { first = entry; armored = box.part === 'armor'; }
      }
      if (armored) continue;
      const point = origin.addScaledVector(target.velocity, time);
      if (!clearPath(muzzle, point, world)) continue;
      bestDot = dot;
      best = { target, direction, point, time, angle: 0 };
    }
  }
  if (best) best.angle = Math.acos(Math.min(1, bestDot));
  return best;
}