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/flight.ts
/**
 * Momentum-based biplane flight on a spherical world. Pure simulation: no scene,
 * DOM or timing dependencies, so it runs identically in tests.
 *
 * Body axes (three.js convention): nose = -Z, up = +Y, right wing = +X.
 * Body angular velocity: x = pitch (positive nose up), y = yaw (positive nose
 * left), z = roll (positive right wing up / roll left).
 */
import { Matrix4, Quaternion, Vector3 } from 'three';
import { DAMAGE, FLIGHT, GRAVITY, PLANET_RADIUS, ROCKETS } from './constants.ts';
import { clamp, smoothstep } from './noise.ts';

export type ControlInput = {
  /** Optional beginner recovery blend; ordinary flight keeps full trim damping. */
  pitchRecovery?: number;
  /** -1..1, positive = nose up. */
  pitch: number;
  /** -1..1, positive = roll right. */
  roll: number;
  /** -1..1, positive = yaw right. */
  yaw: number;
  /** -1..1 rate of throttle change. */
  throttleDelta: number;
  /** Optional absolute throttle 0..1 (touch slider); overrides throttleDelta. */
  throttleSet: number | null;
  boost: boolean;
  /** Hold to fire the guns. */
  fire: boolean;
  /** Press to launch a rocket (one per press). */
  rocket: boolean;
};

export const neutralInput = (): ControlInput => ({
  pitch: 0,
  roll: 0,
  yaw: 0,
  throttleDelta: 0,
  throttleSet: null,
  boost: false,
  fire: false,
  rocket: false,
});

export type CrashCause = 'ground' | 'water' | 'obstacle' | 'midair' | 'shotdown';

export type DamagePart = 'left' | 'right' | 'tail';
export type Damage = Record<DamagePart, number>;

export type FlightState = {
  /** Last active planet-relative pitch command, in simulation seconds. */
  beginnerPitchTime: number;
  position: Vector3;
  velocity: Vector3;
  orientation: Quaternion;
  angularVelocity: Vector3;
  throttle: number;
  boost: number;
  boosting: boolean;
  boostLockout: number;
  alive: boolean;
  crashTimer: number;
  crashCause: CrashCause | null;
  /** Telemetry for HUD/effects. */
  airspeed: number;
  aoa: number;
  sideslip: number;
  stalled: boolean;
  /** Direction the wing drops when the stall breaks (±1). */
  wingDrop: number;
  /** 0..1 how deep into the stall/buffet. */
  stallAmount: number;
  altitude: number;
  groundClearance: number;
  gForce: number;
  /** Simulated seconds, used for deterministic buffet. */
  time: number;
  /** Battle damage per part, 0..1. */
  damage: Damage;
  /** A part is destroyed: the plane can no longer fly. */
  crippled: boolean;
  gunCooldown: number;
  /** Which gun fires next (±1). */
  gunSide: number;
  /** Rockets ready on the wings. */
  rockets: number;
  /** 0..1 progress reloading the next rocket. */
  rocketCharge: number;
  /** Seconds until another rocket can launch. */
  rocketCooldown: number;
  /** Which wing launches next (±1, right is +1). */
  rocketSide: number;
  /** Trigger held last step: one rocket per press. */
  rocketHeld: boolean;
};

export type World = {
  /** Height of ground/sea surface above sea level along a unit direction. */
  surfaceHeight(x: number, y: number, z: number): number;
  /** Seabed/terrain height (negative under sea). */
  terrainHeight(x: number, y: number, z: number): number;
  /** Returns true when a sphere at point p with radius r overlaps an obstacle. */
  hitsObstacle(p: Vector3, r: number): boolean;
  /** Upper bound on surfaceHeight anywhere; lets points above it skip the terrain lookup. */
  maxSurfaceHeight?: number;
};

/** True when point p (at distance r from the centre) is below the ground or sea. */
export function underSurface(world: World, p: Vector3, r = p.length()): boolean {
  if (r > PLANET_RADIUS + (world.maxSurfaceHeight ?? Infinity)) return false;
  return r < PLANET_RADIUS + world.surfaceHeight(p.x / r, p.y / r, p.z / r);
}

export function createFlightState(): FlightState {
  return {
    beginnerPitchTime: -Infinity,
    position: new Vector3(0, PLANET_RADIUS + FLIGHT.spawnAltitude, 0),
    velocity: new Vector3(0, 0, -FLIGHT.cruiseSpeed),
    orientation: new Quaternion(),
    angularVelocity: new Vector3(),
    throttle: 0.85,
    boost: 1,
    boosting: false,
    boostLockout: 0,
    alive: true,
    crashTimer: 0,
    crashCause: null,
    airspeed: FLIGHT.cruiseSpeed,
    aoa: 0,
    sideslip: 0,
    stalled: false,
    wingDrop: 1,
    stallAmount: 0,
    altitude: FLIGHT.spawnAltitude,
    groundClearance: FLIGHT.spawnAltitude,
    gForce: 1,
    time: 0,
    damage: { left: 0, right: 0, tail: 0 },
    crippled: false,
    gunCooldown: 0,
    gunSide: 1,
    rockets: ROCKETS.capacity,
    rocketCharge: 0,
    rocketCooldown: 0,
    rocketSide: -1,
    rocketHeld: true,
  };
}

/** Lift coefficient with a sharp post-stall drop and flat-plate behaviour at high AoA. */
export function liftCoefficient(aoa: number): number {
  const a = Math.abs(aoa);
  const sign = Math.sign(aoa);
  const clMax = FLIGHT.liftSlope * FLIGHT.stallAoA;
  if (a <= FLIGHT.stallAoA) return FLIGHT.liftSlope * aoa;
  const decayed = clMax * (0.4 + 0.6 * Math.exp(-(a - FLIGHT.stallAoA) * 25));
  const flatPlate = 0.75 * Math.sin(2 * Math.min(a, Math.PI / 2));
  return sign * Math.max(decayed * (a < Math.PI / 2 ? 1 : 0), flatPlate);
}

export function airDensity(altitude: number): number {
  return Math.exp(-Math.max(0, altitude - FLIGHT.thinAirAltitude) / FLIGHT.densityScale);
}

const tmp = {
  up: new Vector3(),
  forward: new Vector3(),
  right: new Vector3(),
  bodyUp: new Vector3(),
  velBody: new Vector3(),
  velDir: new Vector3(),
  lift: new Vector3(),
  force: new Vector3(),
  inv: new Quaternion(),
  dq: new Quaternion(),
  axis: new Vector3(),
};

/** Place the plane in the air above direction `dir`, flying level along `heading`. */
export function spawnAt(state: FlightState, world: World, dir: Vector3, heading: Vector3): void {
  state.beginnerPitchTime = -Infinity;
  const up = dir.clone().normalize();
  const ground = world.surfaceHeight(up.x, up.y, up.z);
  const radius = PLANET_RADIUS + Math.max(ground + 60, FLIGHT.spawnAltitude);
  state.position.copy(up).multiplyScalar(radius);
  state.altitude = radius - PLANET_RADIUS;
  state.groundClearance = state.altitude - Math.max(0, ground);
  const forward = heading.clone().addScaledVector(up, -heading.dot(up)).normalize();
  const right = new Vector3().crossVectors(forward, up).normalize();
  const back = forward.clone().negate();
  // Rotation whose columns are right, up, back (nose = -Z).
  state.orientation.setFromRotationMatrix(new Matrix4().makeBasis(right, up, back));
  state.velocity.copy(forward).multiplyScalar(FLIGHT.cruiseSpeed);
  state.angularVelocity.set(0, 0, 0);
  state.throttle = 0.85;
  state.boost = 1;
  state.boosting = false;
  state.boostLockout = 0;
  state.alive = true;
  state.crashTimer = 0;
  state.crashCause = null;
  state.stalled = false;
  state.stallAmount = 0;
  repair(state);
}

export function repair(state: FlightState): void {
  state.damage.left = 0;
  state.damage.right = 0;
  state.damage.tail = 0;
  state.crippled = false;
  state.gunCooldown = 0;
  state.rockets = ROCKETS.capacity;
  state.rocketCharge = 0;
  state.rocketCooldown = 0;
  state.rocketHeld = true;
}

/** Apply one hit to a part. Returns true when this hit destroyed the part. */
export function applyHit(state: FlightState, part: DamagePart, amount: number = part === 'tail' ? DAMAGE.perHitTail : DAMAGE.perHitWing): boolean {
  if (!state.alive) return false;
  const before = state.damage[part];
  state.damage[part] = Math.min(1, before + amount);
  if (state.damage[part] >= 1) state.crippled = true;
  return before < 1 && state.damage[part] >= 1;
}

export function stepFlight(state: FlightState, input: ControlInput, world: World, dt: number): void {
  state.time += dt;
  if (!state.alive) {
    state.crashTimer = Math.max(0, state.crashTimer - dt);
    return;
  }
  const { up, forward, right, bodyUp, velBody, velDir, lift, force, inv, dq, axis } = tmp;
  const radius = state.position.length();
  up.copy(state.position).divideScalar(radius);
  state.altitude = radius - PLANET_RADIUS;

  forward.set(0, 0, -1).applyQuaternion(state.orientation);
  right.set(1, 0, 0).applyQuaternion(state.orientation);
  bodyUp.set(0, 1, 0).applyQuaternion(state.orientation);

  // --- Throttle and boost ---------------------------------------------------
  if (input.throttleSet !== null) state.throttle = clamp(input.throttleSet, 0, 1);
  else state.throttle = clamp(state.throttle + input.throttleDelta * FLIGHT.throttleRate * dt, 0, 1);

  state.boostLockout = Math.max(0, state.boostLockout - dt);
  state.boosting = input.boost && state.boost > 0 && state.boostLockout <= 0;
  if (state.boosting) {
    state.boost = Math.max(0, state.boost - FLIGHT.boostDrain * dt);
    if (state.boost <= 0) state.boostLockout = FLIGHT.boostCooldown;
  } else {
    state.boost = Math.min(1, state.boost + FLIGHT.boostRecharge * dt);
  }

  // --- Relative wind --------------------------------------------------------
  const speed = state.velocity.length();
  state.airspeed = speed;
  inv.copy(state.orientation).invert();
  velBody.copy(state.velocity).applyQuaternion(inv);
  const aoa = speed > 0.5 ? Math.atan2(-velBody.y, -velBody.z) : 0;
  const sideslip = speed > 0.5 ? Math.atan2(velBody.x, Math.hypot(velBody.y, velBody.z)) : 0;
  state.aoa = aoa;
  state.sideslip = sideslip;
  const rho = airDensity(state.altitude);
  const q = FLIGHT.aeroK * rho * speed * speed;
  const qNorm = (rho * speed * speed) / (FLIGHT.cruiseSpeed * FLIGHT.cruiseSpeed);

  const absAoa = Math.abs(aoa);
  // Stall latch with hysteresis: once the wing breaks it stays stalled until
  // the angle of attack is well below critical, i.e. the nose has come down.
  if (!state.stalled && absAoa > FLIGHT.stallAoA * 1.03 && speed > 1) {
    state.stalled = true;
    state.wingDrop = sideslip >= 0 ? 1 : -1;
  } else if (state.stalled && absAoa < FLIGHT.stallAoA * 0.7) {
    state.stalled = false;
  }
  state.stallAmount = state.stalled
    ? Math.max(0.55, smoothstep(FLIGHT.stallAoA, FLIGHT.stallAoA * 1.5, absAoa))
    : 0.3 * smoothstep(FLIGHT.stallAoA * 0.8, FLIGHT.stallAoA, absAoa);

  // --- Battle damage ----------------------------------------------------------
  const { left, right: rightDamage, tail } = state.damage;
  const damageSum = left + rightDamage + tail;
  const crippled = state.crippled;
  const leftGone = left >= 1;
  const rightGone = rightDamage >= 1;
  const tailGone = tail >= 1;
  const liftFactor = crippled
    ? leftGone || rightGone
      ? DAMAGE.crippledLift
      : 0.75
    : 1 - DAMAGE.wingLift * (left + rightDamage);

  // --- Forces (per unit mass) -----------------------------------------------
  force.set(0, 0, 0);
  if (speed > 0.5) {
    velDir.copy(state.velocity).divideScalar(speed);
    const cl = liftCoefficient(aoa) * (state.stalled ? 0.7 : 1) * liftFactor;
    lift.crossVectors(right, velDir);
    if (lift.lengthSq() > 1e-8) force.addScaledVector(lift.normalize(), q * cl);
    const stallDrag = absAoa > FLIGHT.stallAoA ? 0.9 * Math.sin(Math.min(absAoa, Math.PI / 2)) : 0;
    const cd =
      FLIGHT.cd0 + FLIGHT.inducedK * cl * cl + stallDrag + 0.35 * Math.abs(sideslip) + DAMAGE.extraDrag * damageSum;
    force.addScaledVector(velDir, -q * cd);
    force.addScaledVector(right, -q * FLIGHT.sideForce * sideslip);
  }
  const forwardSpeed = Math.max(0, state.velocity.dot(forward));
  const thrust =
    FLIGHT.staticThrust * state.throttle * Math.max(0, 1 - forwardSpeed / FLIGHT.propMaxSpeed) * Math.sqrt(rho);
  force.addScaledVector(forward, thrust + (state.boosting ? FLIGHT.boostThrust : 0));
  force.addScaledVector(up, -GRAVITY);
  // Curvature compensation: on a planet this small, cruise speed is close to
  // orbital. Pull the plane around the curve so "level" follows the surface and
  // flight feels like it does over a flat world.
  const radialSpeed = state.velocity.dot(up);
  const tangentialSq = Math.max(0, speed * speed - radialSpeed * radialSpeed);
  force.addScaledVector(up, -tangentialSq / radius);

  state.velocity.addScaledVector(force, dt);
  // Felt load: everything but gravity, along the body up axis, in g.
  state.gForce = (force.dot(bodyUp) + GRAVITY * up.dot(bodyUp)) / GRAVITY;

  // --- Rotation -------------------------------------------------------------
  const authority =
    clamp((rho * speed * speed) / (FLIGHT.authoritySpeed * FLIGHT.authoritySpeed), 0.12, 1.15) *
    (crippled ? DAMAGE.crippledAuthority : 1);
  // AoA limiter: at speed a hard pull turns tightly on the edge of the stall;
  // when slow the limiter fades out and over-pulling stalls the wings.
  let pitchCmd = input.pitch * (1 - DAMAGE.tailAuthority * Math.min(1, tail));
  // A stalled wing leaves the elevator little pull authority.
  if (state.stalled && pitchCmd > 0) pitchCmd *= 0.25;
  else if (pitchCmd > 0) {
    const limiter = 0.15 + 0.82 * smoothstep(FLIGHT.stallSpeed * 1.15, FLIGHT.stallSpeed * 1.75, speed);
    pitchCmd *= 1 - limiter * smoothstep(FLIGHT.stallAoA * 0.55, FLIGHT.stallAoA * 0.95, aoa);
  }

  // How fast the flight path itself is turning, in body axes. The nose follows it.
  let pathPitchRate = 0;
  let pathYawRate = 0;
  if (speed > 1) {
    axis.crossVectors(velDir, force).divideScalar(speed);
    pathPitchRate = axis.dot(right);
    pathYawRate = axis.dot(bodyUp);
  }
  // Auto-trim: stability holds the angle of attack that supports the plane in
  // level flight, capped below the stall so slow flight sinks instead of stalling.
  // In a moderate bank it adds pull like a coordinated turn; inverted it neutralizes.
  const bankCos = up.dot(bodyUp);
  const turnFactor = bankCos > 0.15 ? 1 / Math.max(bankCos, 0.65) : 0;
  // Vertical-speed damping levels the plane off gently when the stick is released.
  const climbRate = state.velocity.dot(up);
  const trimLift = Math.max(0, GRAVITY * turnFactor - FLIGHT.climbDamping * climbRate * (input.pitchRecovery ?? 1));
  const trimAoa =
    speed > 1 ? clamp(trimLift / Math.max(q * FLIGHT.liftSlope, 1e-3), 0, FLIGHT.stallAoA * 0.75) : 0;
  // Weathervane stability stays partly effective in a stall, so the nose drops
  // and pushing forward regains airspeed.
  const stab = clamp(qNorm, 0.2, 1.2);
  // The stall break: the nose drops and a wing goes down, stronger when slow.
  const breakStrength = state.stalled ? 1 - Math.min(1, qNorm) : 0;

  // Damage: a holed wing lifts less, so the plane rolls toward it (positive w.z
  // rolls left). A shot-up tail trims the nose down. Destroyed parts overwhelm
  // what's left of the controls and the plane spirals or tumbles down.
  const rollBias = crippled
    ? (leftGone ? DAMAGE.crippledRoll : 0) - (rightGone ? DAMAGE.crippledRoll : 0)
    : DAMAGE.wingRoll * (left - rightDamage) * Math.min(1, qNorm + 0.3);
  const pitchBias = tailGone
    ? DAMAGE.crippledPitch * (1 + 0.6 * Math.sin(state.time * 2.3))
    : DAMAGE.tailPitch * tail;
  const trimWeight = crippled ? 0.25 : 1;

  const targetPitch =
    pitchCmd * FLIGHT.maxPitchRate * authority +
    pathPitchRate +
    FLIGHT.pitchStability * (trimAoa - aoa) * stab * trimWeight -
    0.9 * breakStrength -
    pitchBias;
  const targetRoll =
    -input.roll * FLIGHT.maxRollRate * authority + state.wingDrop * 0.9 * breakStrength + rollBias;
  // Auto-coordination: a little rudder goes with roll input.
  const targetYaw =
    -(input.yaw + input.roll * 0.18) * FLIGHT.maxYawRate * authority +
    pathYawRate -
    FLIGHT.yawStability * sideslip * Math.max(qNorm, 0.3);

  const w = state.angularVelocity;
  w.x += (targetPitch - w.x) * Math.min(1, 8 * dt);
  w.z += (targetRoll - w.z) * Math.min(1, 6 * dt);
  w.y += (targetYaw - w.y) * Math.min(1, 6 * dt);

  // Stall buffet and wing drop: a deep stall with the stick held back wobbles.
  if (state.stallAmount > 0) {
    w.z += Math.sin(state.time * 7.3 + aoa * 10) * 1.6 * state.stallAmount * dt;
  }

  // Gentle wings-level tendency when the stick is centered.
  if (Math.abs(input.roll) < 0.1 && !crippled) {
    const bank = Math.asin(clamp(right.dot(up), -1, 1));
    w.z += clamp(bank, -0.6, 0.6) * 0.9 * dt;
  }

  const angle = w.length() * dt;
  if (angle > 0) {
    axis.copy(w).normalize();
    dq.setFromAxisAngle(axis, angle);
    state.orientation.multiply(dq).normalize();
  }

  // --- Integrate position and check collisions --------------------------------
  state.position.addScaledVector(state.velocity, dt);
  const r2 = state.position.length();
  up.copy(state.position).divideScalar(r2);
  const ground = world.terrainHeight(up.x, up.y, up.z);
  const surface = Math.max(0, ground);
  state.altitude = r2 - PLANET_RADIUS;
  state.groundClearance = state.altitude - surface;
  if (state.groundClearance < FLIGHT.crashClearance) {
    crash(state, state.crippled ? 'shotdown' : ground < 0 ? 'water' : 'ground');
  } else if (world.hitsObstacle(state.position, 1.6)) {
    crash(state, state.crippled ? 'shotdown' : 'obstacle');
  }
}

export function crash(state: FlightState, cause: CrashCause): void {
  state.alive = false;
  state.crashCause = cause;
  state.crashTimer = FLIGHT.respawnDelay;
  state.boosting = false;
  state.stalled = false;
  state.stallAmount = 0;
}

/** Collision sphere radius around each plane for plane-to-plane contact. */
export const PLANE_RADIUS = 2.3;

/**
 * Mid-air collisions between pilots: any two live planes whose spheres touch
 * both crash. Returns the index pairs that collided this step.
 */
export function collidePlanes(states: readonly FlightState[]): [number, number][] {
  const hits: [number, number][] = [];
  const reach = (PLANE_RADIUS * 2) ** 2;
  for (let i = 0; i < states.length; i++) {
    for (let j = i + 1; j < states.length; j++) {
      const a = states[i]!;
      const b = states[j]!;
      if (a.alive && b.alive && a.position.distanceToSquared(b.position) < reach) hits.push([i, j]);
    }
  }
  for (const [i, j] of hits) {
    crash(states[i]!, 'midair');
    crash(states[j]!, 'midair');
  }
  return hits;
}

/** Speed the plane settles at in level flight at full throttle, for reference. */
export function estimateLevelSpeed(): number {
  let v = 30;
  for (let i = 0; i < 200; i++) {
    const cl = GRAVITY / (FLIGHT.aeroK * v * v);
    const drag = FLIGHT.aeroK * v * v * (FLIGHT.cd0 + FLIGHT.inducedK * cl * cl);
    const thrust = FLIGHT.staticThrust * Math.max(0, 1 - v / FLIGHT.propMaxSpeed);
    v += (thrust - drag) * 0.5;
  }
  return v;
}