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/camera.ts
import { PerspectiveCamera, Vector3 } from 'three';
import { PLANET_RADIUS } from './sim/constants.ts';
import type { FlightState } from './sim/flight.ts';
import { surfaceHeight } from './sim/terrain.ts';

export type CameraMode = 'chase' | 'far' | 'cockpit';
export const CAMERA_MODES: CameraMode[] = ['chase', 'far', 'cockpit'];

const v = {
  up: new Vector3(),
  forward: new Vector3(),
  bodyUp: new Vector3(),
  desired: new Vector3(),
  target: new Vector3(),
  camUp: new Vector3(),
  side: new Vector3(),
};

/** Pilot's eye in body axes, seated behind the windscreen and under the upper wing. */
const COCKPIT_EYE = { up: 0.9, forward: -0.45 };

/** Keep a camera position at least `clearance` metres above the ground or sea. */
function keepAboveGround(position: Vector3, clearance: number): void {
  const r = position.length();
  const floor = PLANET_RADIUS + surfaceHeight(position.x / r, position.y / r, position.z / r) + clearance;
  if (r < floor) position.multiplyScalar(floor / r);
}

/**
 * Vertical field of view for a viewport: 68° normally, opened up on tall/narrow
 * split-screen views so the horizontal view never drops below ~80°.
 */
export function baseFov(aspect: number): number {
  const minHorizontal = (80 * Math.PI) / 180;
  const vertical = (2 * Math.atan(Math.tan(minHorizontal / 2) / Math.max(0.1, aspect)) * 180) / Math.PI;
  return Math.min(95, Math.max(68, vertical));
}

/** Chase camera that blends the planet's local up with the plane's roll. */
export function createFlightCamera(aspect: number) {
  const camera = new PerspectiveCamera(68, aspect, 0.9, 4000);
  const smoothedUp = new Vector3(0, 1, 0);
  let orbit = 0;
  let initialized = false;
  let shakeT = 0;

  return {
    camera,
    snap() {
      initialized = false;
    },
    update(state: FlightState, mode: CameraMode, dt: number, crashPoint: Vector3 | null) {
      v.up.copy(state.position).normalize();
      if (crashPoint) {
        // Slow orbit around the wreck.
        orbit += dt * 0.35;
        v.up.copy(crashPoint).normalize();
        v.side.set(1, 0, 0).cross(v.up);
        if (v.side.lengthSq() < 1e-4) v.side.set(0, 0, 1).cross(v.up);
        v.side.normalize();
        const other = new Vector3().crossVectors(v.up, v.side);
        v.desired
          .copy(crashPoint)
          .addScaledVector(v.side, Math.cos(orbit) * 32)
          .addScaledVector(other, Math.sin(orbit) * 32)
          .addScaledVector(v.up, 16);
        keepAboveGround(v.desired, 10);
        camera.position.lerp(v.desired, 1 - Math.exp(-dt * 2));
        keepAboveGround(camera.position, 4);
        camera.up.copy(v.up);
        camera.lookAt(crashPoint);
        return;
      }
      orbit = 0;
      // Inside the cockpit the plane's own parts are centimetres away.
      const near = mode === 'cockpit' ? 0.12 : 0.9;
      if (camera.near !== near) {
        camera.near = near;
        camera.updateProjectionMatrix();
      }
      v.forward.set(0, 0, -1).applyQuaternion(state.orientation);
      v.bodyUp.set(0, 1, 0).applyQuaternion(state.orientation);

      if (mode === 'cockpit') {
        // The pilot's eyes: just above the windscreen, looking past the cowling
        // and propeller with the upper wing overhead.
        camera.position
          .copy(state.position)
          .addScaledVector(v.bodyUp, COCKPIT_EYE.up)
          .addScaledVector(v.forward, COCKPIT_EYE.forward);
        camera.up.copy(v.bodyUp);
        camera.lookAt(v.target.copy(camera.position).add(v.forward).addScaledVector(v.bodyUp, -0.02));
        initialized = false;
      } else {
        const back = mode === 'far' ? 26 : 12.5;
        const height = mode === 'far' ? 7 : 3.4;
        v.camUp.copy(v.up).lerp(v.bodyUp, 0.4).normalize();
        smoothedUp.lerp(v.camUp, 1 - Math.exp(-dt * 5)).normalize();
        // On a planet this small the horizon dips steeply with altitude. Tilt the
        // view down by part of that dip so the curving horizon stays in frame.
        const dip = Math.acos(Math.min(1, PLANET_RADIUS / Math.max(PLANET_RADIUS, state.position.length())));
        const tilt = Math.tan(Math.min(0.45, dip * 0.5));
        v.desired
          .copy(state.position)
          .addScaledVector(v.forward, -back)
          .addScaledVector(smoothedUp, height)
          .addScaledVector(v.up, back * tilt);
        if (!initialized) {
          camera.position.copy(v.desired);
          smoothedUp.copy(v.camUp);
          initialized = true;
        } else {
          // Stiff enough to keep up at boost speed, loose enough to feel momentum.
          camera.position.lerp(v.desired, 1 - Math.exp(-dt * 7));
        }
        keepAboveGround(camera.position, 2);
        camera.up.copy(smoothedUp);
        camera.lookAt(
          v.target
            .copy(state.position)
            .addScaledVector(v.forward, 10)
            .addScaledVector(smoothedUp, 1.2)
            .addScaledVector(v.up, -10 * tilt),
        );
      }

      // Stall buffet shake.
      if (state.stallAmount > 0.05) {
        shakeT += dt * 30;
        const s = state.stallAmount * 0.012;
        camera.rotateX(Math.sin(shakeT * 1.3) * s);
        camera.rotateZ(Math.sin(shakeT * 0.9) * s);
      }
      const fov = baseFov(camera.aspect) + (mode === 'cockpit' ? 8 : 0) + (state.boosting ? 10 : 0) + Math.min(6, Math.max(0, state.airspeed - 62) * 0.15);
      camera.fov += (fov - camera.fov) * (1 - Math.exp(-dt * 4));
      camera.updateProjectionMatrix();
    },
  };
}