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/main.ts
import {
  beginnerControls, planetBeginnerControls, createCrashHistory, recordBeginnerCrash, dismissBeginnerOffer,
  type CrashHistory,
} from './sim/beginner.ts';
import { castRay, findAim, gunMuzzle, type AimSolution } from './sim/aim.ts';
import { STORAGE_DOMAIN } from '@napplet/sdk';
import {
  AmbientLight,
  Color,
  DirectionalLight,
  Fog,
  HemisphereLight,
  PerspectiveCamera,
  Scene,
  Vector3,
  WebGLRenderer,
} from 'three';
import { createAudio } from './audio.ts';
import { CAMERA_MODES, createFlightCamera, type CameraMode } from './camera.ts';
import { runtimeHasDomain } from './domain-availability.js';
import { createInput, mergeControls } from './input.ts';
import { onHostSettings } from './napplet-settings.ts';
import { defaultSettings, loadSettings, saveSettings, type Settings } from './settings.ts';
import { GUNS, PLANET_RADIUS, SIM_HZ } from './sim/constants.ts';
import { LIVERIES, MAX_PLAYERS, planRoster, screenIndicator, splitLayout, steerToward, type ViewRect } from './players.ts';
import {
  collidePlanes,
  createFlightState,
  neutralInput,
  spawnAt,
  stepFlight,
  type ControlInput,
  type FlightState,
  type World,
} from './sim/flight.ts';
import { mulberry32 } from './sim/noise.ts';
import { createGuns, stepGuns } from './sim/guns.ts';
import { createRockets, rocketsChasing, seekerTarget, stepRockets } from './sim/rockets.ts';
import { generateObstacles } from './sim/obstacles.ts';
import { RIFT, surfaceHeight, terrainHeight } from './sim/terrain.ts';
import { createEffects } from './world/effects.ts';
import { createObstacleMeshes } from './world/obstacle-meshes.ts';
import { PALETTE } from './world/palette.ts';
import { createHorizonCuller } from './world/chunks.ts';
import { createPlanet } from './world/planet.ts';
import { createPlaneModel, type PlaneModel } from './world/plane-model.ts';
import { createSky } from './world/sky.ts';
import { createPerfOverlay } from './perf.ts';
import { createRocketMeshes } from './world/rockets.ts';
import { createTracers } from './world/tracers.ts';
import './styles.css';

// Poly Dogfight keeps its own art direction: the host theme is not applied.
// See docs/napplet-visual-design.md; the scene and HUD palettes are app-owned.

function $<T extends HTMLElement>(selector: string): T {
  const element = document.querySelector<T>(selector);
  if (!element) throw new Error(`Missing required element: ${selector}`);
  return element;
}

const ui = {
  app: $<HTMLElement>('#app'),
  canvas: $<HTMLCanvasElement>('#scene'),
  views: $<HTMLElement>('#views'),
  spare: $<HTMLElement>('#spareView'),
  viewTemplate: $<HTMLTemplateElement>('#viewTemplate'),
  pilotsNote: $<HTMLElement>('#pilotsNote'),
  menu: $<HTMLElement>('#menu'),
  flightModes: $<HTMLElement>('#flightModes'),
  menuTitle: $<HTMLElement>('#menuTitle'),
  flyButton: $<HTMLButtonElement>('#flyButton'),
  startHint: $<HTMLElement>('#startHint'),
  invert: $<HTMLInputElement>('#invertToggle'),
  mute: $<HTMLInputElement>('#muteToggle'),
  saveNote: $<HTMLElement>('#saveNote'),
  pauseButton: $<HTMLButtonElement>('#pauseButton'),
  cameraButton: $<HTMLButtonElement>('#cameraButton'),
  touch: $<HTMLElement>('#touchControls'),
  throttleKnob: $<HTMLElement>('#throttleKnob'),
  error: $<HTMLElement>('#errorNotice'),
};

// ---------------------------------------------------------------- Renderer

let renderer: WebGLRenderer;
try {
  renderer = new WebGLRenderer({
    canvas: ui.canvas,
    antialias: window.devicePixelRatio < 2,
    powerPreference: 'high-performance',
  });
} catch (error) {
  ui.error.hidden = false;
  ui.menu.hidden = true;
  throw error;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
const perf = createPerfOverlay(renderer, ui.app);

const scene = new Scene();
scene.background = new Color(PALETTE.skyHorizon);
scene.fog = new Fog(PALETTE.fog, 380, 1900);

const sun = new DirectionalLight(PALETTE.sunLight, 2.1);
const hemi = new HemisphereLight(PALETTE.hemiSky, PALETTE.hemiGround, 1.1);
scene.add(sun, sun.target, hemi, new AmbientLight(0xffffff, 0.25));

const sky = createSky();
scene.add(sky.mesh);
const horizon = createHorizonCuller();
const field = generateObstacles();
const obstacleMeshes = createObstacleMeshes(field, horizon);
const planet = createPlanet(horizon, obstacleMeshes.solidChunks);
scene.add(planet.land, planet.sea);
scene.add(obstacleMeshes.group);
const effects = createEffects();
scene.add(effects.group);
const guns = createGuns();
const tracers = createTracers();
scene.add(tracers.mesh);
const rockets = createRockets();
const rocketMeshes = createRocketMeshes();
scene.add(rocketMeshes.group);

// ---------------------------------------------------------------- Simulation

const world: World = {
  surfaceHeight,
  terrainHeight,
  hitsObstacle: (p, r) => field.hits(p, r),
  // The mesh samples the terrain every ~9 m; leave room for peaks between samples.
  maxSurfaceHeight: Math.max(0, planet.maxHeight) + 12,
};
const random = mulberry32(Date.now() >>> 0);

type ViewUi = {
  root: HTMLElement;
  pilot: HTMLElement;
  pilotName: HTMLElement;
  speed: HTMLElement;
  alt: HTMLElement;
  throttle: HTMLElement;
  boost: HTMLElement;
  stall: HTMLElement;
  crash: HTMLElement;
  crashTitle: HTMLElement;
  respawn: HTMLElement;
  beginner: HTMLElement;
  offer: HTMLElement;
  indicators: HTMLElement;
  reticle: HTMLElement;
  aimPoint: HTMLElement;
  flightPath: HTMLElement;
  hitMarker: HTMLElement;
  rocketMeter: HTMLElement;
  rocketPips: HTMLElement[];
  incoming: HTMLElement;
  lockBox: HTMLElement;
  /** Edge and on-screen markers: other pilots by seat, incoming rockets from ROCKET_KEY. */
  markers: Map<number, PilotIndicator>;
};

/**
 * How far ahead the outside-view sight and flight-path marker reach. Short
 * enough that the chase camera keeps both on screen through hard turns.
 */
const SIGHT_RANGE = 150;

/** Marker keys for incoming rockets, after the four pilot seats. */
const ROCKET_KEY = 100;

type PilotIndicator = { root: HTMLElement; label: HTMLElement; distance: HTMLElement; text: string };

/** One plane, its controller, camera and split-screen view. */
type Pilot = {
  aim: AimSolution | null;
  aimUpdated: number;
  /** Ray-cast reach of the gun line and the flight path, refreshed with the aim. */
  sightDistance: number;
  pathDistance: number;
  hitUntil: number;
  armoredHit: boolean;
  /** The plane this pilot's rocket seeker sees now, for the lock box. */
  lock: FlightState | null;
  lockUpdated: number;
  beginner: boolean;
  pendingBeginner: boolean;
  history: CrashHistory;
  /** Livery and label index, kept for as long as this pilot stays in. */
  seat: number;
  pad: number | null;
  state: FlightState;
  model: PlaneModel;
  view: ReturnType<typeof createFlightCamera>;
  /** Pilot 1's camera mode lives in the saved settings. */
  cameraMode: CameraMode;
  crashPoint: Vector3 | null;
  /** Boost stays off after resuming until the button is released. */
  boostGate: boolean;
  rect: ViewRect;
  ui: ViewUi;
};

const pilots: Pilot[] = [];

function createViewUi(seat: number): ViewUi {
  const fragment = ui.viewTemplate.content.cloneNode(true) as DocumentFragment;
  const root = fragment.querySelector<HTMLElement>('.view')!;
  const ref = (name: string) => root.querySelector<HTMLElement>(`[data-ref="${name}"]`)!;
  root.style.setProperty('--pilot', LIVERIES[seat]!.css);
  root.setAttribute('aria-label', `Pilot ${seat + 1}`);
  ui.views.append(root);
  const view: ViewUi = {
    root,
    pilot: ref('pilot'),
    pilotName: ref('pilotName'),
    speed: ref('speed'),
    alt: ref('alt'),
    throttle: ref('throttle'),
    boost: ref('boost'),
    stall: ref('stall'),
    crash: ref('crash'),
    crashTitle: ref('crashTitle'),
    respawn: ref('respawn'),
    beginner: ref('beginner'),
    offer: ref('offer'),
    indicators: ref('indicators'),
    reticle: ref('reticle'),
    aimPoint: ref('aimPoint'),
    flightPath: ref('flightPath'),
    hitMarker: ref('hitMarker'),
    rocketMeter: ref('rocketMeter'),
    rocketPips: Array.from(root.querySelectorAll<HTMLElement>('[data-ref="rocketPip"]')),
    incoming: ref('incoming'),
    lockBox: ref('lockBox'),
    markers: new Map(),
  };
  view.pilotName.textContent = `P${seat + 1}`;
  return view;
}

function createPilot(seat: number, pad: number | null): Pilot {
  const livery = LIVERIES[seat]!;
  const model = createPlaneModel(livery);
  scene.add(model.group, model.marker);
  const viewUi = createViewUi(seat);
  viewUi.root.querySelector('[data-ref="accept"]')!.addEventListener('click', () => answerOffer(pilots.find((p) => p.ui === viewUi)!, true));
  viewUi.root.querySelector('[data-ref="decline"]')!.addEventListener('click', () => answerOffer(pilots.find((p) => p.ui === viewUi)!, false));
  return {
    aim: null,
    aimUpdated: -Infinity,
    sightDistance: SIGHT_RANGE,
    pathDistance: SIGHT_RANGE,
    hitUntil: 0,
    armoredHit: false,
    lock: null,
    lockUpdated: -Infinity,
    beginner: pilots[0]?.beginner ?? false,
    pendingBeginner: false,
    history: createCrashHistory(),
    seat,
    pad,
    state: createFlightState(),
    model,
    view: createFlightCamera(1),
    cameraMode: 'chase',
    crashPoint: null,
    boostGate: true,
    rect: { x: 0, y: 0, w: 1, h: 1 },
    ui: viewUi,
  };
}

function removePilot(pilot: Pilot): void {
  scene.remove(pilot.model.group, pilot.model.marker);
  pilot.ui.root.remove();
}

const forwardOf = (state: FlightState) => new Vector3(0, 0, -1).applyQuaternion(state.orientation);

/** True when a fresh spawn has room: no obstacle nearby or ahead, no other plane close. */
function spawnIsClear(pilot: Pilot): boolean {
  const { position } = pilot.state;
  const ahead = position.clone().addScaledVector(forwardOf(pilot.state), 40);
  if (field.hits(position, 12) || field.hits(ahead, 12)) return false;
  return pilots.every((other) => other === pilot || !other.state.alive || other.state.position.distanceTo(position) > 25);
}

/** Opening shot: above the Great Rift canyon, heading along it. */
function spawnAtRift(pilot: Pilot): void {
  const along = RIFT.arcStart + 0.02;
  const e1 = new Vector3(...RIFT.e1);
  const e2 = new Vector3(...RIFT.e2);
  const dir = e1.clone().multiplyScalar(Math.cos(along)).addScaledVector(e2, Math.sin(along));
  const heading = e1.clone().multiplyScalar(-Math.sin(along)).addScaledVector(e2, Math.cos(along));
  spawnAt(pilot.state, world, dir, heading);
}

function spawnRandom(pilot: Pilot): void {
  for (let attempt = 0; attempt < 40; attempt++) {
    const u = random() * 2 - 1;
    const theta = random() * Math.PI * 2;
    const r = Math.sqrt(1 - u * u);
    const dir = new Vector3(r * Math.cos(theta), u, r * Math.sin(theta));
    const heading = new Vector3(random() - 0.5, random() - 0.5, random() - 0.5).normalize();
    if (Math.abs(heading.dot(dir)) > 0.95) continue;
    spawnAt(pilot.state, world, dir, heading);
    // Keep a clear bubble ahead of the spawn point and away from other pilots.
    if (spawnIsClear(pilot) && pilots.every((o) => o === pilot || !o.state.alive || o.state.position.distanceTo(pilot.state.position) > 80))
      return;
  }
}

/** New pilots join in formation off pilot 1's wing so everyone starts together. */
const FORMATION = [0, 32, -32, 64];
function spawnBeside(pilot: Pilot, leader: Pilot): void {
  if (!leader.state.alive) return spawnRandom(pilot);
  const up = leader.state.position.clone().normalize();
  const forward = forwardOf(leader.state).addScaledVector(up, 0);
  forward.addScaledVector(up, -forward.dot(up)).normalize();
  const right = new Vector3().crossVectors(forward, up).normalize();
  const offset = FORMATION[pilot.seat] ?? 40;
  const point = leader.state.position
    .clone()
    .addScaledVector(right, offset)
    .addScaledVector(forward, -Math.abs(offset) * 0.4);
  spawnAt(pilot.state, world, point.normalize(), forward);
  if (!spawnIsClear(pilot)) spawnRandom(pilot);
}

pilots.push(createPilot(0, null));
spawnAtRift(pilots[0]!);

// ---------------------------------------------------------------- Settings and input

let settings: Settings = defaultSettings();
let preferencesChanged = false;
const audio = createAudio();
const input = createInput(
  {
    stick: $('#stick'),
    stickKnob: $('#stickKnob'),
    throttle: $('#throttleSlider'),
    boost: $('#boostButton'),
    yawLeft: $('#yawLeft'),
    yawRight: $('#yawRight'),
    fire: $('#fireButton'),
    rocket: $('#rocketButton'),
  },
);

function modeControls(): void {
  const existing = Array.from(ui.flightModes.querySelectorAll<HTMLInputElement>('input'));
  if (existing.length === pilots.length && existing.every((el, i) => el.dataset.seat === String(pilots[i]!.seat))) {
    existing.forEach((el, i) => { el.checked = pilots[i]!.beginner; });
    const beginner = pilots[0]!.beginner;
    $('#flightHint').textContent = beginner
      ? 'Beginner: up / W climbs, down / S descends; left / right turns. Full-speed steering and boost stay available. Throttle is automatic; release the stick to level out.'
      : 'Stick back / S pulls the nose up. Stalled? Push the nose down and add power.';
    ui.touch.classList.toggle('beginner', beginner);
    return;
  }
  ui.flightModes.replaceChildren();
  for (const pilot of pilots) {
    const label = document.createElement('label');
    label.className = 'toggle';
    const toggle = document.createElement('input');
    toggle.type = 'checkbox';
    toggle.dataset.seat = String(pilot.seat);
    toggle.checked = pilot.beginner;
    toggle.addEventListener('change', () => setBeginner(pilot, toggle.checked));
    label.append(toggle, ` Beginner controls${pilots.length > 1 ? ` — P${pilot.seat + 1}` : ''}`);
    ui.flightModes.append(label);
  }
}
function setBeginner(pilot: Pilot, enabled: boolean): void {
  preferencesChanged = true;
  pilot.beginner = enabled;
  pilot.pendingBeginner = false;
  dismissBeginnerOffer(pilot.history, pilot.state.time);
  if (pilot === pilots[0]) {
    settings.beginner = enabled;
    void persist();
  }
  modeControls();
}
function answerOffer(pilot: Pilot, accepted: boolean): void {
  if (!pilot) return;
  dismissBeginnerOffer(pilot.history, pilot.state.time);
  if (accepted) {
    if (pilot.state.alive) setBeginner(pilot, true);
    else pilot.pendingBeginner = true;
    pilot.boostGate = true;
  }
}
function applySettings(): void {
  ui.invert.checked = settings.invertPitch;
  ui.mute.checked = settings.muted;
  audio.setMuted(settings.muted);
  modeControls();
}

async function persist(): Promise<void> {
  const saved = await saveSettings(settings);
  ui.saveNote.hidden = saved;
  if (!saved) {
    ui.saveNote.textContent = runtimeHasDomain(STORAGE_DOMAIN)
      ? 'Preferences could not be saved this time.'
      : 'Preferences last for this session only.';
  }
}

void loadSettings().then((loaded) => {
  if (preferencesChanged) return;
  settings = loaded;
  pilots[0]!.beginner = loaded.beginner;
  applySettings();
});
onHostSettings((values) => {
  if (typeof values.invertPitch === 'boolean') settings.invertPitch = values.invertPitch;
  if (typeof values.muted === 'boolean') settings.muted = values.muted;
  applySettings();
});
applySettings();

ui.invert.addEventListener('change', () => {
  preferencesChanged = true;
  settings.invertPitch = ui.invert.checked;
  void persist();
});
ui.mute.addEventListener('change', () => {
  preferencesChanged = true;
  settings.muted = ui.mute.checked;
  audio.setMuted(settings.muted);
  void persist();
});

// The menu's controls list follows whichever device was used last.
type ControlsDevice = 'keyboard' | 'gamepad';
let controlsDevice: ControlsDevice = 'keyboard';
let knownPads = 0;
function showControlsFor(device: ControlsDevice): void {
  if (device === controlsDevice) return;
  controlsDevice = device;
  const panel = $<HTMLElement>('#controlsPanel');
  panel.dataset.device = device;
  for (const list of panel.querySelectorAll<HTMLElement>('[data-for]')) list.hidden = list.dataset.for !== device;
  $('#controlsDevice').textContent = device === 'gamepad' ? 'Gamepad' : 'Keyboard';
}
window.addEventListener('keydown', () => showControlsFor('keyboard'), { capture: true });

const coarse = window.matchMedia('(pointer: coarse)');
let touchSeen = coarse.matches;
const updateTouch = () => ui.touch.classList.toggle('on', touchSeen && !paused);
window.addEventListener(
  'pointerdown',
  (event) => {
    if (event.pointerType === 'touch' && !touchSeen) {
      touchSeen = true;
      updateTouch();
    }
  },
  { capture: true },
);

// ---------------------------------------------------------------- Game flow

let paused = true;
let started = false;

function setPaused(value: boolean): void {
  paused = value;
  ui.menu.hidden = !paused;
  if (paused) {
    input.clear();
    ui.menuTitle.textContent = started ? 'Paused' : 'Ready for take-off';
    ui.flyButton.textContent = started ? 'Resume' : 'Fly';
    ui.flyButton.focus({ preventScroll: true });
    audio.suspend();
  } else {
    started = true;
    for (const pilot of pilots) pilot.boostGate = true;
    audio.start();
    ui.canvas.focus?.();
  }
  updateTouch();
}

ui.flyButton.addEventListener('click', () => setPaused(false));
ui.pauseButton.addEventListener('click', () => setPaused(!paused));
ui.cameraButton.addEventListener('click', () => cycleCamera(pilots[0]!));

const cameraModeOf = (pilot: Pilot): CameraMode => (pilot.seat === 0 ? settings.camera : pilot.cameraMode);

function cycleCamera(pilot: Pilot): void {
  const next = CAMERA_MODES[(CAMERA_MODES.indexOf(cameraModeOf(pilot)) + 1) % CAMERA_MODES.length]!;
  pilot.view.snap();
  if (pilot.seat === 0) {
    settings.camera = next;
    void persist();
  } else {
    pilot.cameraMode = next;
  }
}

const CRASH_TEXT = {
  ground: 'Crashed!',
  water: 'Splashdown!',
  obstacle: 'Ouch — hit something!',
  midair: 'Mid-air collision!',
  shotdown: 'Shot down!',
} as const;

// ---------------------------------------------------------------- Split screen

let frameWidth = 1;
let frameHeight = 1;
let spareRect: ViewRect | null = null;
const overview = new PerspectiveCamera(40, 1, 50, 6000);

function placeView(element: HTMLElement, rect: ViewRect): void {
  element.style.left = `${rect.x * 100}%`;
  element.style.top = `${rect.y * 100}%`;
  element.style.width = `${rect.w * 100}%`;
  element.style.height = `${rect.h * 100}%`;
}

function layout(): void {
  const { views, spare } = splitLayout(pilots.length, frameWidth, frameHeight);
  pilots.forEach((pilot, i) => {
    pilot.rect = views[i]!;
    placeView(pilot.ui.root, pilot.rect);
    pilot.ui.pilot.hidden = pilots.length < 2;
    const camera = pilot.view.camera;
    camera.aspect = (pilot.rect.w * frameWidth) / Math.max(1, pilot.rect.h * frameHeight);
    camera.updateProjectionMatrix();
  });
  ui.views.classList.toggle('split', pilots.length > 1);
  ui.app.classList.toggle('split', pilots.length > 1);
  spareRect = spare;
  ui.spare.hidden = !spare;
  if (spare) {
    placeView(ui.spare, spare);
    overview.aspect = (spare.w * frameWidth) / Math.max(1, spare.h * frameHeight);
    overview.updateProjectionMatrix();
  }
  ui.pilotsNote.textContent =
    pilots.length >= MAX_PLAYERS
      ? `${pilots.length} pilots in split screen. All seats are full.`
      : pilots.length > 1
        ? `${pilots.length} pilots in split screen. Another controller can join (up to ${MAX_PLAYERS}).`
        : `Up to ${MAX_PLAYERS} pilots: each extra controller joins in split screen.`;
}

/** Add pilots for newly detected controllers and drop those whose controller left. */
function syncRoster(activePads: number[]): void {
  const plan = planRoster(
    pilots.map((pilot) => pilot.pad),
    activePads,
  );
  const unchanged = plan.length === pilots.length && plan.every((slot, i) => slot.from === i && slot.pad === pilots[i]!.pad);
  if (unchanged) return;
  const kept = new Set(plan.map((slot) => slot.from));
  pilots.forEach((pilot, i) => {
    if (!kept.has(i)) removePilot(pilot);
  });
  const previous = pilots.slice();
  pilots.length = 0;
  for (const slot of plan) {
    if (slot.from !== null) {
      const pilot = previous[slot.from]!;
      pilot.pad = slot.pad;
      pilots.push(pilot);
      continue;
    }
    const used = new Set(pilots.map((p) => p.seat).concat(previous.filter((_, i) => kept.has(i)).map((p) => p.seat)));
    const seat = [0, 1, 2, 3].find((s) => !used.has(s)) ?? pilots.length;
    const pilot = createPilot(seat, slot.pad);
    pilots.push(pilot);
    spawnBeside(pilot, pilots[0]!);
  }
  layout();
  modeControls();
}

// ---------------------------------------------------------------- Frame loop

const sunAxis = new Vector3(0.6, 0.35, 0.72).normalize();
const sunDir = new Vector3();
const up = new Vector3();
const STEP = 1 / SIM_HZ;
let accumulator = 0;
let last = performance.now();
let lastHud = 0;

function resize(): void {
  frameWidth = Math.max(1, ui.app.clientWidth);
  frameHeight = Math.max(1, ui.app.clientHeight);
  renderer.setSize(frameWidth, frameHeight, false);
  layout();
}
new ResizeObserver(resize).observe(ui.app);
resize();

function updateHud(now: number): void {
  if (now - lastHud < 66) return;
  lastHud = now;
  for (const { state, ui: view } of pilots) {
    view.speed.textContent = String(Math.round(state.airspeed * 3.6));
    view.alt.textContent = String(Math.max(0, Math.round(state.groundClearance)));
    view.throttle.style.transform = `scaleX(${state.throttle.toFixed(3)})`;
    view.boost.style.transform = `scaleX(${state.boost.toFixed(3)})`;
    view.boost.classList.toggle('locked', state.boostLockout > 0);
    view.rocketPips.forEach((pip, i) => {
      const fill = i < state.rockets ? 1 : i === state.rockets ? state.rocketCharge : 0;
      pip.style.transform = `scaleX(${fill.toFixed(3)})`;
      pip.classList.toggle('loading', i >= state.rockets);
    });
    view.rocketPips[0]?.parentElement?.classList.toggle('cooling', state.rocketCooldown > 0);
    view.rocketMeter.setAttribute('aria-label', `Rockets ready: ${state.rockets}`);
    view.incoming.hidden = !state.alive || !rocketsChasing(rockets, state).length;
    view.stall.hidden = !(state.alive && state.stalled && state.stallAmount > 0.15);
    const pilot = pilots.find((p) => p.ui === view)!;
    view.beginner.hidden = !pilot.beginner;
    view.offer.hidden = !pilot.history.offered;
    view.crash.hidden = state.alive || pilot.history.offered;
    if (!state.alive) {
      view.crashTitle.textContent = CRASH_TEXT[state.crashCause ?? 'ground'];
      view.respawn.textContent = String(Math.max(1, Math.ceil(state.crashTimer)));
    }
  }
  // Touch controls and the throttle slider belong to pilot 1.
  const lead = pilots[0]!.state;
  ui.throttleKnob.style.bottom = `calc(${(lead.throttle * 100).toFixed(1)}% - ${(lead.throttle * 22).toFixed(1)}px)`;
}

/** Point the sun and sky at a viewer so it's always a bright afternoon where they fly. */
function lightFor(viewer: Vector3, focus: Vector3): void {
  up.copy(focus).normalize();
  sunDir.copy(sunAxis).addScaledVector(up, -sunAxis.dot(up));
  if (sunDir.lengthSq() < 1e-4) sunDir.set(1, 0, 0).addScaledVector(up, -up.x);
  sunDir.normalize().multiplyScalar(0.55).addScaledVector(up, 0.8).normalize();
  sun.position.copy(viewer).addScaledVector(sunDir, 100);
  sun.target.position.copy(viewer);
  hemi.position.copy(up);
  sky.update(viewer, viewer.clone().normalize(), sunDir);
}

/** Show other pilots' beacons in this view, sized to stay readable at range. */
function placeMarkers(viewer: Vector3, self: Pilot | null, scale: number): void {
  for (const pilot of pilots) {
    const marker = pilot.model.marker;
    const { state } = pilot;
    marker.visible = pilots.length > 1 && pilot !== self && state.alive;
    if (!marker.visible) continue;
    marker.position.copy(state.position).addScaledVector(up.copy(state.position).normalize(), 4.5);
    const distance = viewer.distanceTo(marker.position);
    marker.visible = distance > 30;
    marker.scale.setScalar(Math.min(14, Math.max(0.6, distance * scale)));
    marker.scale.y *= 1.6;
  }
}

const indicatorPoint = new Vector3();

function indicatorFor(view: ViewUi, key: number): PilotIndicator {
  let marker = view.markers.get(key);
  if (marker) return marker;
  const rocket = key >= ROCKET_KEY;
  const root = document.createElement('div');
  root.className = rocket ? 'indicator rocket' : 'indicator';
  root.style.setProperty('--pilot', rocket ? 'var(--danger)' : LIVERIES[key]!.css);
  const arrow = document.createElement('span');
  arrow.className = 'indicator-arrow';
  const label = document.createElement('span');
  label.className = 'indicator-label';
  label.textContent = rocket ? 'ROCKET' : `P${key + 1}`;
  const distance = document.createElement('small');
  label.append(' ', distance);
  root.append(arrow, label);
  view.indicators.append(root);
  marker = { root, label, distance, text: '' };
  view.markers.set(key, marker);
  return marker;
}

const formatDistance = (metres: number) =>
  metres < 1000 ? `${Math.round(metres / 10) * 10} m` : `${(metres / 1000).toFixed(1)} km`;

/**
 * Point each pilot toward the others: a tag under planes in view, an arrow
 * pinned to the screen edge for those out of view. Arrows follow the curve of
 * the planet so a pilot on the far side reads as "over the horizon that way".
 */
function placeIndicators(viewer: Pilot): void {
  const camera = viewer.view.camera;
  const width = viewer.rect.w * frameWidth;
  const height = viewer.rect.h * frameHeight;
  const tanY = Math.tan((camera.fov * Math.PI) / 360);
  const margin = Math.min(16, width * 0.08, height * 0.08);
  const shown = new Set<number>();
  const labels: { x: number; y: number }[] = [];
  // Rockets locked onto this pilot come first so their warnings keep their place.
  const targets: { key: number; position: Vector3 }[] = [];
  if (viewer.state.alive && !paused)
    rocketsChasing(rockets, viewer.state).forEach((rocket, i) => targets.push({ key: ROCKET_KEY + i, position: rocket.position }));
  for (const other of pilots)
    if (other !== viewer && other.state.alive) targets.push({ key: other.seat, position: other.state.position });
  for (const { key, position } of targets) {
    const { aim, distance } = steerToward(camera.position, position);
    // Close enough to see clearly: no tag cluttering the view (rockets always warn).
    if (distance < 35 && key < ROCKET_KEY) continue;
    indicatorPoint.set(aim.x, aim.y, aim.z).applyMatrix4(camera.matrixWorldInverse);
    const spot = screenIndicator(indicatorPoint, tanY * camera.aspect, tanY, width, height, margin);
    const marker = indicatorFor(viewer.ui, key);
    shown.add(key);
    // The label sits inward from the arrow (or under the plane). When it would
    // cover another label, slide the whole marker along its edge (or just the
    // tag, in view) so each arrow keeps its own label.
    const lx = spot.onScreen ? 0 : -Math.cos(spot.angle) * 46;
    let ly = spot.onScreen ? 26 : -Math.sin(spot.angle) * 24;
    const alongTop = !spot.onScreen && (spot.y <= margin + 0.5 || spot.y >= height - margin - 0.5);
    const covered = () => labels.some((l) => Math.abs(l.x - spot.x - lx) < 78 && Math.abs(l.y - spot.y - ly) < 24);
    for (let tries = 0; tries < 3 && covered(); tries++) {
      if (spot.onScreen) ly += spot.y < height / 2 ? 24 : -24;
      else if (alongTop) spot.x += spot.x < width / 2 ? 80 : -80;
      else spot.y += spot.y < height / 2 ? 24 : -24;
    }
    labels.push({ x: spot.x + lx, y: spot.y + ly });
    marker.root.hidden = false;
    marker.root.classList.toggle('onscreen', spot.onScreen);
    marker.root.style.transform = `translate(${spot.x.toFixed(1)}px, ${spot.y.toFixed(1)}px)`;
    marker.root.style.setProperty('--angle', `${spot.angle.toFixed(3)}rad`);
    marker.root.style.setProperty('--lx', `${lx.toFixed(1)}px`);
    marker.root.style.setProperty('--ly', `${ly.toFixed(1)}px`);
    const text = formatDistance(distance);
    if (text !== marker.text) marker.distance.textContent = marker.text = text;
  }
  for (const [key, marker] of viewer.ui.markers) if (!shown.has(key)) marker.root.hidden = true;
}

const sightOrigin = new Vector3();
const sightDirection = new Vector3();
const pathDirection = new Vector3();
const sightScreen = new Vector3();

/** The gun line (rounds inherit the plane's velocity) and the flight path, from the plane. */
function sightRays(state: FlightState): void {
  sightOrigin.copy(gunMuzzle(state, 0));
  sightDirection.copy(forwardOf(state)).multiplyScalar(GUNS.muzzleSpeed).add(state.velocity).normalize();
  if (state.velocity.lengthSq() > 1) pathDirection.copy(state.velocity).normalize();
  else pathDirection.copy(forwardOf(state));
}

/** Put `element` where `point` lands in the camera, clamped inside the view; false if behind. */
function placeOnScreen(element: HTMLElement, point: Vector3, camera: PerspectiveCamera): boolean {
  sightScreen.copy(point).project(camera);
  if (sightScreen.z > 1) return false;
  const x = Math.max(-0.92, Math.min(0.92, sightScreen.x));
  const y = Math.max(-0.92, Math.min(0.92, sightScreen.y));
  element.style.left = `${(x + 1) * 50}%`;
  element.style.top = `${(1 - y) * 50}%`;
  return true;
}

/** Same prediction as the next shot, projected independently into each camera. */
function placeAim(pilot: Pilot, now: number): void {
  const { reticle, aimPoint, flightPath, hitMarker } = pilot.ui;
  reticle.hidden = paused || !pilot.state.alive;
  const outside = cameraModeOf(pilot) !== 'cockpit';
  if (reticle.hidden) { pilot.aim = null; pilot.aimUpdated = -Infinity; }
  else if (now - pilot.aimUpdated >= 50) {
    pilot.aim = findAim(pilot.state, pilots.map(p => p.state), world);
    if (outside) {
      sightRays(pilot.state);
      pilot.sightDistance = castRay(sightOrigin, sightDirection, SIGHT_RANGE, world);
      pilot.pathDistance = castRay(pilot.state.position, pathDirection, SIGHT_RANGE, world);
    }
    pilot.aimUpdated = now;
  }
  // Outside the cockpit the screen centre isn't where the guns point: cast the
  // gun line and the flight path ahead and mark where each meets the world.
  flightPath.hidden = true;
  if (reticle.hidden || !outside) {
    reticle.style.left = reticle.style.top = '';
  } else {
    sightRays(pilot.state);
    const camera = pilot.view.camera;
    const sight = sightScreen.copy(sightOrigin).addScaledVector(sightDirection, pilot.sightDistance);
    if (!placeOnScreen(reticle, sight, camera)) reticle.style.left = reticle.style.top = '';
    const path = sightScreen.copy(pilot.state.position).addScaledVector(pathDirection, pilot.pathDistance);
    flightPath.hidden = !placeOnScreen(flightPath, path, camera);
    flightPath.classList.toggle('impact', pilot.pathDistance < SIGHT_RANGE);
  }
  if (pilot.aim && !pilots.some(p => p.state === pilot.aim!.target && p.state.alive)) pilot.aim = null;
  const aim = pilot.aim;
  reticle.classList.toggle('assisted', !!aim);
  reticle.setAttribute('aria-label', aim ? 'Gun sight: assisted shot available' : 'Gun sight');
  hitMarker.hidden = reticle.hidden || now > pilot.hitUntil;
  hitMarker.classList.toggle('armored', pilot.armoredHit);
  aimPoint.hidden = true;
  if (aim) {
    const point = aim.point.clone().project(pilot.view.camera);
    if (point.z >= -1 && point.z <= 1 && Math.abs(point.x) <= 1 && Math.abs(point.y) <= 1) {
      aimPoint.hidden = false;
      aimPoint.style.left = `${(point.x + 1) * 50}%`;
      aimPoint.style.top = `${(1 - point.y) * 50}%`;
    }
  }
}

const seekerNose = new Vector3();

/** The plane a rocket launched now would lock onto, boxed in this pilot's view. */
function placeLock(pilot: Pilot, now: number): void {
  const { state } = pilot;
  const { lockBox } = pilot.ui;
  if (paused || !state.alive) {
    pilot.lock = null;
    pilot.lockUpdated = -Infinity;
  } else if (now - pilot.lockUpdated >= 50) {
    const forward = forwardOf(state);
    seekerNose.copy(state.position).addScaledVector(forward, 3);
    pilot.lock = seekerTarget(seekerNose, forward, pilots.map((p) => p.state), state, world);
    pilot.lockUpdated = now;
  }
  const lock = pilot.lock;
  lockBox.hidden = true;
  if (!lock || !lock.alive) return;
  const point = lock.position.clone().project(pilot.view.camera);
  if (point.z < -1 || point.z > 1 || Math.abs(point.x) > 1 || Math.abs(point.y) > 1) return;
  lockBox.hidden = false;
  lockBox.classList.toggle('waiting', state.rockets === 0 || state.rocketCooldown > 0);
  lockBox.style.left = `${(point.x + 1) * 50}%`;
  lockBox.style.top = `${(1 - point.y) * 50}%`;
}

function setViewport(rect: ViewRect): void {
  const x = Math.round(rect.x * frameWidth);
  const w = Math.round((rect.x + rect.w) * frameWidth) - x;
  const top = Math.round(rect.y * frameHeight);
  const h = Math.round((rect.y + rect.h) * frameHeight) - top;
  const y = frameHeight - top - h;
  renderer.setViewport(x, y, w, h);
  renderer.setScissor(x, y, w, h);
}

const centroid = new Vector3();
function renderOverview(rect: ViewRect, time: number): void {
  centroid.set(0, 0, 0);
  for (const pilot of pilots) centroid.add(pilot.state.position);
  if (centroid.lengthSq() < 1) centroid.set(0, 1, 0);
  centroid.normalize();
  // Hang high above the pilots and drift slowly around them.
  const side = new Vector3(0, 1, 0).cross(centroid);
  if (side.lengthSq() < 1e-4) side.set(1, 0, 0);
  side.normalize();
  const view = centroid.clone().applyAxisAngle(side, 0.35).applyAxisAngle(centroid, time * 0.05);
  overview.position.copy(view).multiplyScalar(PLANET_RADIUS * 3.2);
  overview.up.copy(side);
  overview.lookAt(0, 0, 0);
  lightFor(overview.position, centroid);
  horizon.cullFor(overview.position);
  placeMarkers(overview.position, null, 0.012);
  for (const pilot of pilots) pilot.model.group.visible = false;
  const fog = scene.fog as Fog;
  const [near, far] = [fog.near, fog.far];
  fog.near = 4000;
  fog.far = 8000;
  setViewport(rect);
  renderer.render(scene, overview);
  fog.near = near;
  fog.far = far;
}

/** Body-frame centres of the damageable parts, for smoke trails. */
const SMOKE_POINTS = {
  left: new Vector3(-2.1, 0.4, -0.5),
  right: new Vector3(2.1, 0.4, -0.5),
  tail: new Vector3(0, 0.5, 2.4),
} as const;
const smokeAt = new Vector3();
const smokeDrift = new Vector3();

/** Damaged parts trail smoke: light when hurt, black once destroyed. */
function emitDamageSmoke(pilot: Pilot, dt: number): void {
  const { state } = pilot;
  if (!state.alive) return;
  for (const part of ['left', 'right', 'tail'] as const) {
    const amount = state.damage[part];
    if (amount < 0.3 || random() > amount * 16 * dt) continue;
    smokeAt.copy(SMOKE_POINTS[part]).applyQuaternion(state.orientation).add(state.position);
    smokeDrift.copy(state.velocity).multiplyScalar(0.15).addScaledVector(smokeAt.clone().normalize(), 1.5);
    const darkness = amount >= 1 ? 2 : amount > 0.65 ? 1 : 0;
    effects.puff(smokeAt, smokeDrift, darkness, amount >= 1 ? 1.7 : 1);
  }
}

function audioMix(): void {
  const alive = pilots.filter((pilot) => pilot.state.alive);
  const n = Math.max(1, alive.length);
  audio.update({
    throttle: alive.reduce((sum, p) => sum + p.state.throttle, 0) / n,
    airspeed: alive.reduce((sum, p) => sum + p.state.airspeed, 0) / n,
    boosting: alive.some((p) => p.state.boosting),
    stall: Math.max(0, ...alive.map((p) => p.state.stallAmount)),
    alive: alive.length > 0,
    time: pilots[0]!.state.time,
  });
}

function frame(now: number): void {
  const dt = Math.min(0.1, (now - last) / 1000);
  last = now;
  perf.begin();

  const frameInput = input.poll(dt);
  syncRoster(frameInput.activePads);
  const padCount = frameInput.activePads.length;
  if (frameInput.padAny || padCount > knownPads) showControlsFor('gamepad');
  else if (padCount === 0) showControlsFor('keyboard');
  knownPads = padCount;
  for (const pilot of pilots) {
    const pad = frameInput.pads.find((p) => p.index === pilot.pad);
    if (pilot.history.offered) {
      if (pad?.accept) answerOffer(pilot, true);
      else if (pad?.decline) answerOffer(pilot, false);
    } else if (paused && pad?.mode) setBeginner(pilot, !pilot.beginner);
  }
  if (frameInput.pause) setPaused(!paused);
  else if (paused && frameInput.pads.some((pad) => pad.accept)) setPaused(false);
  if (frameInput.mute) {
    settings.muted = !settings.muted;
    applySettings();
    void persist();
  }

  // Each pilot's controls: pilot 1 also listens to keyboard and touch.
  const controls: ControlInput[] = pilots.map((pilot, i) => {
    const pad = frameInput.pads.find((p) => p.index === pilot.pad);
    if (pad?.camera || (i === 0 && frameInput.localCamera)) cycleCamera(pilot);
    let control = pad?.control ?? neutralInput();
    if (i === 0) control = mergeControls(frameInput.local, control);
    if (!pilot.state.alive) control = neutralInput();
    if (pilot.boostGate) pilot.boostGate = control.boost;
    if (pilot.boostGate) control.boost = false;
    return control;
  });

  perf.simBegin();
  if (!paused) {
    accumulator += dt;
    // Steps run in a burst each frame; spread their sounds back out over the
    // next frame so gunfire keeps an even cadence.
    let stepIndex = 0;
    while (accumulator >= STEP) {
      const wasAlive = pilots.map((pilot) => pilot.state.alive);
      pilots.forEach((pilot, i) => {
        const raw = controls[i]!;
        const control = pilot.beginner
          ? (cameraModeOf(pilot) === 'cockpit' ? beginnerControls : planetBeginnerControls)(pilot.state, raw)
          : { ...raw, pitch: settings.invertPitch ? -raw.pitch : raw.pitch };
        stepFlight(pilot.state, control, world, STEP);
      });
      const states = pilots.map((pilot) => pilot.state);
      const gunfire = stepGuns(
        guns,
        states,
        controls.map((control) => control.fire),
        world,
        STEP,
      );
      for (const shooter of gunfire.shots) audio.shot(stepIndex * STEP, states[shooter]!.gunSide * 0.2);
      for (const confirmation of gunfire.confirmations) {
        const shooter = pilots.find(p => p.state === confirmation.owner);
        if (shooter && (!confirmation.armored || shooter.hitUntil <= performance.now() || shooter.armoredHit)) {
          shooter.hitUntil = performance.now() + 180;
          shooter.armoredHit = confirmation.armored;
        }
      }
      for (const hit of gunfire.hits) {
        const target = pilots[hit.target]!;
        target.model.addHole(hit.part, hit.local);
        effects.spark(hit.world);
        audio.hit(hit.destroyed);
        if (hit.destroyed) {
          const livery = LIVERIES[target.seat]!;
          effects.shards(hit.world, target.state.velocity, [livery.wing, livery.body, livery.trim], 12);
        }
      }
      for (const point of gunfire.impacts) effects.spark(point);
      const salvo = stepRockets(
        rockets,
        states,
        controls.map((control) => control.rocket),
        world,
        STEP,
      );
      for (const launch of salvo.launches) audio.rocketLaunch(stepIndex * STEP, launch.side * 0.3);
      for (const hit of salvo.hits) {
        const target = pilots[hit.target]!;
        target.model.addHole(hit.part, hit.local);
        effects.blast(hit.world, hit.world.clone().normalize());
        const livery = LIVERIES[target.seat]!;
        effects.shards(hit.world, target.state.velocity, [livery.wing, livery.body, livery.trim], hit.destroyed ? 16 : 6);
        audio.rocketBoom(stepIndex * STEP, true);
        const shooter = pilots.find((p) => p.state === hit.owner);
        if (shooter && shooter !== target) {
          shooter.hitUntil = performance.now() + 300;
          shooter.armoredHit = false;
        }
      }
      for (const point of salvo.explosions) {
        effects.blast(point, point.clone().normalize());
        audio.rocketBoom(stepIndex * STEP);
      }
      collidePlanes(states);
      pilots.forEach((pilot, i) => {
        const { state } = pilot;
        // Absolute throttle (touch slider) applies once.
        controls[i]!.throttleSet = null;
        if (wasAlive[i] && !state.alive) {
          if (!pilot.beginner) recordBeginnerCrash(pilot.history, state.crashCause, state.time);
          pilot.crashPoint = state.position.clone();
          const livery = LIVERIES[pilot.seat]!;
          effects.explode(state.position, state.velocity, state.position.clone().normalize(), [
            livery.body,
            livery.wing,
            livery.trim,
          ]);
          audio.crash();
        }
        if (!state.alive && state.crashTimer <= 0) {
          spawnRandom(pilot);
          if (pilot.pendingBeginner) setBeginner(pilot, true);
          pilot.crashPoint = null;
          pilot.view.snap();
        }
      });
      accumulator -= STEP;
      stepIndex++;
    }
  }

  perf.simEnd();
  const simDt = paused ? 0 : dt;
  const shadows = pilots.map(({ state }) => {
    const n = state.position.clone().normalize();
    return {
      up: n,
      groundPoint: n.clone().multiplyScalar(PLANET_RADIUS + surfaceHeight(n.x, n.y, n.z)),
      clearance: state.groundClearance,
      alive: state.alive,
    };
  });
  effects.update(simDt, shadows);
  tracers.update(guns.bullets);
  rocketMeshes.update(rockets.rockets, simDt, (position, drift) => effects.puff(position, drift, 0, 1.3));
  if (!paused) for (const pilot of pilots) emitDamageSmoke(pilot, dt);
  obstacleMeshes.update(simDt);
  for (const pilot of pilots) {
    pilot.model.group.position.copy(pilot.state.position);
    pilot.model.group.quaternion.copy(pilot.state.orientation);
    pilot.model.update(simDt, pilot.state.throttle, pilot.state.boosting);
    pilot.model.setDamage(pilot.state.damage);
    pilot.model.setRockets(pilot.state.rockets, pilot.state.rocketSide);
    pilot.view.update(pilot.state, cameraModeOf(pilot), dt, pilot.crashPoint);
  }

  renderer.setScissorTest(pilots.length > 1);
  for (const pilot of pilots) {
    const camera = pilot.view.camera;
    for (const other of pilots) {
      other.model.group.visible = other.state.alive;
      other.model.setCockpitView(other === pilot && cameraModeOf(pilot) === 'cockpit');
    }
    lightFor(camera.position, pilot.state.alive ? pilot.state.position : (pilot.crashPoint ?? pilot.state.position));
    horizon.cullFor(camera.position);
    placeMarkers(camera.position, pilot, 0.008);
    setViewport(pilot.rect);
    renderer.render(scene, camera);
    placeIndicators(pilot);
    placeAim(pilot, now);
    placeLock(pilot, now);
  }
  if (spareRect) renderOverview(spareRect, now / 1000);
  renderer.setScissorTest(false);

  audioMix();
  updateHud(now);
  perf.end();
  requestAnimationFrame(frame);
}

// "Press any key" on the title card; buttons and toggles keep their own keys.
window.addEventListener('keydown', (event) => {
  if (!paused || started || event.ctrlKey || event.metaKey || event.altKey) return;
  if (['Escape', 'KeyP', 'Tab', 'Enter', 'Space'].includes(event.code)) return;
  const target = event.target as HTMLElement | null;
  if (target?.closest('input')) return;
  setPaused(false);
});
window.addEventListener('blur', () => {
  if (started && !paused) setPaused(true);
});
window.addEventListener('pagehide', () => input.dispose());

setPaused(true);
// Keep the title-screen camera on the plane while the menu is up.
pilots[0]!.view.update(pilots[0]!.state, settings.camera, 0, null);
updateHud(performance.now() + 1000);
requestAnimationFrame(frame);