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/input.ts
/**
 * One input layer for keyboard, gamepad and touch. Poll once per animation
 * frame; edges (camera, pause, ...) are reported once.
 */
import { createGamepadInput } from './gamepad.ts';
import { neutralInput, type ControlInput } from './sim/flight.ts';
import { clamp } from './sim/noise.ts';

/** One gamepad's controls this frame. */
export type PadInput = {
  index: number;
  control: ControlInput;
  camera: boolean;
  pause: boolean;
  accept: boolean;
  decline: boolean;
  mode: boolean;
};

export type FrameInput = {
  /** Keyboard and touch, which always fly pilot 1. */
  local: ControlInput;
  localCamera: boolean;
  pads: PadInput[];
  /** Controllers that are connected and mapped: each one is a pilot. */
  activePads: number[];
  pause: boolean;
  mute: boolean;
  /** Any button/key/tap this frame — used to start or resume. */
  any: boolean;
  /** A gamepad button was pressed this frame. */
  padAny: boolean;
};

/** Strongest signal wins per axis; boost if either asks. */
export function mergeControls(a: ControlInput, b: ControlInput): ControlInput {
  const pick = (x: number, y: number) => (Math.abs(y) > Math.abs(x) ? y : x);
  return {
    pitch: pick(a.pitch, b.pitch),
    roll: pick(a.roll, b.roll),
    yaw: pick(a.yaw, b.yaw),
    throttleDelta: pick(a.throttleDelta, b.throttleDelta),
    throttleSet: a.throttleSet ?? b.throttleSet,
    boost: a.boost || b.boost,
    fire: a.fire || b.fire,
    rocket: a.rocket || b.rocket,
  };
}

export type TouchElements = {
  stick: HTMLElement;
  stickKnob: HTMLElement;
  throttle: HTMLElement;
  boost: HTMLElement;
  yawLeft: HTMLElement;
  yawRight: HTMLElement;
  fire: HTMLElement;
  rocket: HTMLElement;
};

const FLIGHT_KEYS = new Set([
  'KeyW', 'KeyA', 'KeyS', 'KeyD', 'KeyQ', 'KeyE', 'KeyR', 'KeyF', 'KeyC', 'KeyP', 'KeyM',
  'KeyT', 'KeyG', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'ShiftLeft', 'ShiftRight', 'Escape',
]);

export function createInput(touch: TouchElements) {
  const held = new Set<string>();
  const edges = new Set<string>();
  let anyEdge = false;
  // Smoothed digital axes so keyboard flying isn't twitchy.
  const smooth = { pitch: 0, roll: 0, yaw: 0 };

  const onKeyDown = (event: KeyboardEvent) => {
    if (event.ctrlKey || event.metaKey || event.altKey) return;
    if (FLIGHT_KEYS.has(event.code)) event.preventDefault();
    if (!event.repeat) {
      edges.add(event.code);
      anyEdge = true;
    }
    held.add(event.code);
  };
  const onKeyUp = (event: KeyboardEvent) => held.delete(event.code);
  const clearAll = () => {
    held.clear();
    touchState.stickX = 0;
    touchState.stickY = 0;
    touchState.boost = false;
    touchState.fire = false;
    touchState.rocket = false;
    touchState.yaw = 0;
    touchState.stickPointer = null;
    touch.stickKnob.style.transform = '';
  };
  window.addEventListener('keydown', onKeyDown);
  window.addEventListener('keyup', onKeyUp);
  window.addEventListener('blur', clearAll);
  document.addEventListener('visibilitychange', clearAll);

  // --- Touch -----------------------------------------------------------------
  const capture = (element: HTMLElement, pointerId: number) => {
    try {
      element.setPointerCapture(pointerId);
    } catch {
      // Synthetic or already-released pointers cannot be captured; input still works.
    }
  };
  const touchState = {
    stickPointer: null as number | null,
    stickX: 0,
    stickY: 0,
    throttle: null as number | null,
    boost: false,
    fire: false,
    rocket: false,
    yaw: 0,
  };
  const stickMove = (event: PointerEvent) => {
    if (event.pointerId !== touchState.stickPointer) return;
    const rect = touch.stick.getBoundingClientRect();
    const radius = rect.width / 2;
    let dx = (event.clientX - (rect.left + radius)) / radius;
    let dy = (event.clientY - (rect.top + radius)) / radius;
    const len = Math.hypot(dx, dy);
    if (len > 1) {
      dx /= len;
      dy /= len;
    }
    touchState.stickX = dx;
    touchState.stickY = dy;
    touch.stickKnob.style.transform = `translate(${dx * radius * 0.6}px, ${dy * radius * 0.6}px)`;
  };
  const stickEnd = (event: PointerEvent) => {
    if (event.pointerId !== touchState.stickPointer) return;
    touchState.stickPointer = null;
    touchState.stickX = 0;
    touchState.stickY = 0;
    touch.stickKnob.style.transform = '';
  };
  touch.stick.addEventListener('pointerdown', (event) => {
    event.preventDefault();
    capture(touch.stick, event.pointerId);
    touchState.stickPointer = event.pointerId;
    anyEdge = true;
    stickMove(event);
  });
  touch.stick.addEventListener('pointermove', stickMove);
  touch.stick.addEventListener('pointerup', stickEnd);
  touch.stick.addEventListener('pointercancel', stickEnd);

  let throttlePointer: number | null = null;
  const throttleMove = (event: PointerEvent) => {
    if (event.pointerId !== throttlePointer) return;
    const rect = touch.throttle.getBoundingClientRect();
    touchState.throttle = clamp(1 - (event.clientY - rect.top) / rect.height, 0, 1);
  };
  touch.throttle.addEventListener('pointerdown', (event) => {
    event.preventDefault();
    capture(touch.throttle, event.pointerId);
    throttlePointer = event.pointerId;
    anyEdge = true;
    throttleMove(event);
  });
  touch.throttle.addEventListener('pointermove', throttleMove);
  const throttleEnd = (event: PointerEvent) => {
    if (event.pointerId === throttlePointer) throttlePointer = null;
  };
  touch.throttle.addEventListener('pointerup', throttleEnd);
  touch.throttle.addEventListener('pointercancel', throttleEnd);

  const hold = (element: HTMLElement, set: (down: boolean) => void) => {
    element.addEventListener('pointerdown', (event) => {
      event.preventDefault();
      capture(element, event.pointerId);
      anyEdge = true;
      set(true);
    });
    const release = () => set(false);
    element.addEventListener('pointerup', release);
    element.addEventListener('pointercancel', release);
    element.addEventListener('lostpointercapture', release);
  };
  hold(touch.boost, (down) => (touchState.boost = down));
  hold(touch.fire, (down) => (touchState.fire = down));
  hold(touch.rocket, (down) => (touchState.rocket = down));
  hold(touch.yawLeft, (down) => (touchState.yaw = down ? -1 : touchState.yaw === -1 ? 0 : touchState.yaw));
  hold(touch.yawRight, (down) => (touchState.yaw = down ? 1 : touchState.yaw === 1 ? 0 : touchState.yaw));

  // --- Gamepad ---------------------------------------------------------------
  const gamepad = createGamepadInput(
    {
      roll: [{ axis: 0 }, { button: 14, scale: -1 }, { button: 15 }],
      // Flight convention: stick back (down) = nose up.
      pitch: [{ axis: 1 }, { button: 12, scale: -1 }, { button: 13 }],
      // Triggers are the rudder pedals: LT left, RT right (right stick X too).
      yaw: [{ button: 6, scale: -1 }, { button: 7 }, { axis: 2 }],
      throttleUp: [{ button: 5 }],
      throttleDown: [{ button: 4 }],
      // Face buttons: A boost, B guns, Y rockets, X camera.
      boost: [{ button: 0 }],
      fire: [{ button: 1 }],
      rocket: [{ button: 3 }],
      camera: [{ button: 2 }],
      pause: [{ button: 9 }],
      accept: [{ button: 0 }],
      decline: [{ button: 1 }],
      mode: [{ button: 4 }],
    },
    { deadZone: 0.15 },
  );

  const key = (...codes: string[]) => (codes.some((c) => held.has(c)) ? 1 : 0);
  const approach = (current: number, target: number, rate: number, dt: number) => {
    const step = rate * dt;
    return Math.abs(target - current) <= step ? target : current + Math.sign(target - current) * step;
  };
  const finish = (control: ControlInput) => {
    control.pitch = clamp(control.pitch, -1, 1);
    control.roll = clamp(control.roll, -1, 1);
    control.yaw = clamp(control.yaw, -1, 1);
    return control;
  };

  return {
    poll(dt: number): FrameInput {
      const local = neutralInput();
      const frame = gamepad.poll();

      // Keyboard (smoothed).
      const kPitch = key('KeyS', 'ArrowDown') - key('KeyW', 'ArrowUp');
      const kRoll = key('KeyD', 'ArrowRight') - key('KeyA', 'ArrowLeft');
      const kYaw = key('KeyE') - key('KeyQ');
      smooth.pitch = approach(smooth.pitch, kPitch, kPitch ? 3.5 : 6, dt);
      smooth.roll = approach(smooth.roll, kRoll, kRoll ? 4 : 8, dt);
      smooth.yaw = approach(smooth.yaw, kYaw, 4, dt);
      local.pitch = smooth.pitch;
      local.roll = smooth.roll;
      local.yaw = smooth.yaw;
      local.throttleDelta = key('KeyT', 'ShiftLeft', 'ShiftRight') - key('KeyG');
      local.boost = held.has('Space');
      local.fire = held.has('KeyF') || held.has('Enter');
      local.rocket = held.has('KeyR');

      // Touch.
      const pick = (a: number, b: number) => (Math.abs(b) > Math.abs(a) ? b : a);
      local.pitch = pick(local.pitch, touchState.stickY);
      local.roll = pick(local.roll, touchState.stickX);
      local.yaw = pick(local.yaw, touchState.yaw);
      local.boost ||= touchState.boost;
      local.fire ||= touchState.fire;
      local.rocket ||= touchState.rocket;
      if (touchState.throttle !== null) {
        local.throttleSet = touchState.throttle;
        touchState.throttle = null;
      }

      let pause = edges.has('Escape') || edges.has('KeyP');
      const mute = edges.has('KeyM');
      let any = anyEdge;
      let padAny = false;

      const pads: PadInput[] = [];
      for (const pad of frame.players) {
        if (!pad.mapped) continue;
        const a = pad.actions;
        const control = neutralInput();
        control.pitch = a.pitch?.value ?? 0;
        control.roll = a.roll?.value ?? 0;
        control.yaw = a.yaw?.value ?? 0;
        control.throttleDelta = (a.throttleUp?.value ?? 0) - (a.throttleDown?.value ?? 0);
        control.boost = !!a.boost?.down;
        control.fire = !!a.fire?.down;
        control.rocket = !!a.rocket?.down;
        const camera = !!a.camera?.pressed;
        const padPause = !!a.pause?.pressed;
        pause ||= padPause;
        padAny ||= Object.values(a).some(action => action.pressed);
        pads.push({ index: pad.index, control: finish(control), camera, pause: padPause, accept: !!a.accept?.pressed, decline: !!a.decline?.pressed, mode: !!a.mode?.pressed });
      }
      any ||= padAny;

      const localCamera = edges.has('KeyC');
      edges.clear();
      anyEdge = false;
      const activePads = pads.map((pad) => pad.index);
      return { local: finish(local), localCamera, pads, activePads, pause, mute, any, padAny };
    },
    clear: clearAll,
    dispose() {
      window.removeEventListener('keydown', onKeyDown);
      window.removeEventListener('keyup', onKeyUp);
      window.removeEventListener('blur', clearAll);
      document.removeEventListener('visibilitychange', clearAll);
      gamepad.dispose();
    },
  };
}