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/beginner.ts
/** Beginner steering feeds the existing physics; the cockpit stays attached to the plane. */
import { Vector3 } from 'three';
import { neutralInput, type ControlInput, type FlightState, type CrashCause } from './flight.ts';
import { clamp, smoothstep } from './noise.ts';
import { FLIGHT } from './constants.ts';

/** Outside the cockpit, the pilot steers against the planet's local horizon. */
export function planetBeginnerControls(state: FlightState, steering: ControlInput): ControlInput {
  const input = beginnerControls(state, steering);
  if (!state.alive || state.crippled) return input;
  const up = state.position.clone().normalize();
  const right = new Vector3(1, 0, 0).applyQuaternion(state.orientation);
  const bodyUp = new Vector3(0, 1, 0).applyQuaternion(state.orientation);
  const forward = new Vector3(0, 0, -1).applyQuaternion(state.orientation);
  if (Math.abs(steering.pitch) > 0.05) {
    state.beginnerPitchTime = state.time;
    // Keep the wing's horizontal axis through the top of a loop: deriving it
    // from forward × planet-up would reverse at vertical and prevent loops.
    const pitchAxis = right.clone().projectOnPlane(up);
    if (pitchAxis.lengthSq() < 1e-6) pitchAxis.crossVectors(forward, up);
    if (pitchAxis.lengthSq() < 1e-6) pitchAxis.copy(right);
    pitchAxis.normalize();
    const pull = -steering.pitch;
    // Compose climb and heading requests before translating to the plane's
    // controls. Banking for a turn must not cancel its horizontal steering
    // just because the pilot is also asking to climb or dive.
    const rotation = pitchAxis.multiplyScalar(pull * FLIGHT.maxPitchRate)
      .addScaledVector(up, -steering.roll * FLIGHT.maxPitchRate);
    input.pitch = clamp(rotation.dot(right) / FLIGHT.maxPitchRate, -1, 1);
    input.yaw = clamp(steering.yaw - rotation.dot(bodyUp) / FLIGHT.maxYawRate, -1, 1);
    // Align the wings for the requested maneuver as well as redirecting the
    // nose. Use the nearest horizontal wing attitude, including inverted:
    // ordinary upright bank error would roll the plane over at a loop's top.
    const wingElevation = right.dot(up);
    const verticalAlignment = bodyUp.dot(up);
    const horizonStrength = Math.hypot(wingElevation, verticalAlignment);
    const bank = Math.atan2(-wingElevation, Math.abs(verticalAlignment));
    // Pushing into a dive reverses the bank needed for the same heading turn.
    const targetBank = steering.roll * Math.sign(pull) * Math.PI / 2 * (1 - 0.65 * Math.abs(steering.pitch));
    input.roll = clamp((targetBank - bank) * 2 * horizonStrength + state.angularVelocity.z * 0.4, -1, 1);
  } else {
    // Let the pilot keep the chosen climb/dive for 1.5 seconds, then blend
    // recovery in over another two seconds. Trim's own climb damping must
    // follow the same blend or it would level off before this controller does.
    const recovery = smoothstep(1.5, 3.5, state.time - state.beginnerPitchTime);
    input.pitchRecovery = recovery * 0.35;
    const climb = state.velocity.dot(up) / Math.max(state.velocity.length(), 1);
    const elevation = Math.asin(clamp(forward.dot(up), -1, 1));
    // Velocity alone misses a nose that is still rising ahead of its flight
    // path. Normalize the correction axis: its magnitude must not vanish as
    // body-up becomes horizontal when the nose points straight up or down.
    const levelAxis = new Vector3().crossVectors(forward, up);
    if (levelAxis.lengthSq() < 1e-6) levelAxis.copy(right).projectOnPlane(up);
    levelAxis.normalize();
    const correction = clamp(-elevation * 0.45 - climb * 0.15, -0.4, 0.4) * recovery;
    input.pitch = clamp(input.pitch + correction * levelAxis.dot(right), -1, 1);
    input.yaw = clamp(input.yaw - correction * levelAxis.dot(bodyUp) * FLIGHT.maxPitchRate / FLIGHT.maxYawRate, -1, 1);
  }
  return input;
}

/** Bank (rad) given back from a full turn while sinking, and pull eased per unit climb. */
const LEVEL_BANK = 0.5;
const LEVEL_PULL = 12;

/** Horizontal steering combines bank and elevator at normal combat authority. */
export function beginnerControls(state: FlightState, steering: ControlInput): ControlInput {
  const input = { ...steering, pitch: -steering.pitch, throttleSet: 1, throttleDelta: 0 };
  if (!state.alive) return { ...neutralInput(), fire: steering.fire };
  if (state.crippled) return input;
  const up = state.position.clone().normalize();
  const right = new Vector3(1, 0, 0).applyQuaternion(state.orientation);
  const bodyUp = new Vector3(0, 1, 0).applyQuaternion(state.orientation);
  const bank = Math.atan2(-right.dot(up), bodyUp.dot(up));
  const turn = Math.abs(steering.roll);
  if (turn < 0.05) {
    input.roll = clamp(-bank * 0.75, -1, 1);
  } else {
    // Roll into the requested bank at normal authority, then hold it instead
    // of rolling through it. Full horizontal stick requests a 90-degree bank,
    // shallowed a little while sinking so the wing can hold the turn level.
    const climb = state.velocity.dot(up) / Math.max(state.velocity.length(), 1);
    const targetBank = steering.roll * (Math.PI / 2 - LEVEL_BANK * clamp(-climb * 4, 0, 1));
    input.roll = clamp((targetBank - bank) * 3 + state.angularVelocity.z * 0.5, -turn, turn);
    // A bank alone changes attitude, not direction quickly. Pull into the
    // turn as the wing comes down, just like bank + elevator in normal mode.
    const alignment = Math.max(0, Math.sin(bank) * Math.sign(steering.roll));
    // Ease the pull while climbing so a partial bank turns instead of looping.
    const pull = turn * alignment * clamp(1 - climb * LEVEL_PULL, 0, 1);
    // Vertical stick remains authoritative: full up/down overrides turn pull.
    input.pitch = clamp(input.pitch + pull * (1 - Math.abs(steering.pitch)), -1, 1);
  }
  return input;
}

export type CrashHistory = { times: number[]; nextOffer: number; offered: boolean };
export const createCrashHistory = (): CrashHistory => ({ times: [], nextOffer: 0, offered: false });
export function recordBeginnerCrash(history: CrashHistory, cause: CrashCause | null, time: number): boolean {
  history.times = history.times.filter((t) => time - t <= 120);
  if (cause !== 'ground' && cause !== 'water' && cause !== 'obstacle') return false;
  history.times.push(time);
  if (history.times.length < 3 || time < history.nextOffer || history.offered) return false;
  history.offered = true;
  return true;
}
export function dismissBeginnerOffer(history: CrashHistory, time: number): void {
  history.offered = false;
  history.times = [];
  history.nextOffer = time + 300;
}