Back to Pickle Rustlers
SOURCE / PINNED RELEASE

Made of little things.

Pickle Rustlers

Release
039e51f55e34…
Author-recorded commit
40608586a8a1…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-bad44e7ab35

Archive hash verified: f38e99afa0992052…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/input.ts
// Keyboard, touch and gamepad merged into one set of named actions per frame.
import { KeyboardInput, type KeyboardAction } from './keyboard.js';
import { createGamepadInput, type GamepadBindings } from './gamepad.js';
import { createShellGamepadSource } from './gamepad-source.js';

export interface InputFrame {
  throttle: number; // -1 (brake / reverse) .. 1 (gallop)
  steer: number; // -1 left .. 1 right
  shoot: boolean; // pressed this frame
  lasso: boolean;
  pause: boolean;
  confirm: boolean; // start / continue
}

const bindings: GamepadBindings = {
  moveX: [{ axis: 0 }, { button: 14, scale: -1 }, { button: 15 }],
  moveY: [{ axis: 1 }, { button: 12, scale: -1 }, { button: 13 }],
  gas: [{ button: 7 }],
  brake: [{ button: 6 }],
  shoot: [{ button: 0 }, { button: 5 }],
  lasso: [{ button: 2 }, { button: 1 }, { button: 4 }],
  pause: [{ button: 9 }],
};

export class Input {
  private keyboard: KeyboardInput;
  private listeners = new AbortController();
  private stick = { x: 0, y: 0, id: -1 };
  private touchShoot = false;
  private touchLasso = false;
  private controllerSource = createShellGamepadSource(() => this.gamepad?.reset());
  private gamepad = createGamepadInput(bindings, this.controllerSource);
  touchSeen = false;

  constructor(
    private stickEl: HTMLElement,
    private knobEl: HTMLElement,
    shootBtn: HTMLElement,
    lassoBtn: HTMLElement,
    private onActivity: (kind: 'touch' | 'keys') => void,
    allowAction: (action: KeyboardAction) => boolean,
  ) {
    // KeyboardInput ignores keys typed into text fields, such as the score-card note.
    this.keyboard = new KeyboardInput(() => this.onActivity('keys'), allowAction);
    const signal = this.listeners.signal;
    window.addEventListener('blur', () => this.clear(), { signal });
    document.addEventListener('visibilitychange', () => this.clear(), { signal });

    stickEl.addEventListener('pointerdown', (e) => {
      e.preventDefault();
      this.stick.id = e.pointerId;
      stickEl.setPointerCapture(e.pointerId);
      this.moveStick(e);
      this.markTouch();
    }, { signal });
    stickEl.addEventListener('pointermove', (e) => {
      if (e.pointerId === this.stick.id) this.moveStick(e);
    }, { signal });
    const endStick = (e: PointerEvent) => {
      if (e.pointerId !== this.stick.id) return;
      this.stick = { x: 0, y: 0, id: -1 };
      this.knobEl.style.transform = '';
    };
    stickEl.addEventListener('pointerup', endStick, { signal });
    stickEl.addEventListener('pointercancel', endStick, { signal });
    stickEl.addEventListener('lostpointercapture', endStick, { signal });

    const button = (el: HTMLElement, set: () => void) => {
      el.addEventListener('pointerdown', (e) => {
        e.preventDefault();
        set();
        el.classList.add('down');
        this.markTouch();
      }, { signal });
      const up = () => el.classList.remove('down');
      el.addEventListener('pointerup', up, { signal });
      el.addEventListener('pointercancel', up, { signal });
      el.addEventListener('pointerleave', up, { signal });
    };
    button(shootBtn, () => (this.touchShoot = true));
    button(lassoBtn, () => (this.touchLasso = true));
  }

  private markTouch(): void {
    this.touchSeen = true;
    this.onActivity('touch');
  }

  private moveStick(e: PointerEvent): void {
    const rect = this.stickEl.getBoundingClientRect();
    const r = rect.width / 2;
    let x = (e.clientX - rect.left - r) / r;
    let y = (e.clientY - rect.top - r) / r;
    const len = Math.hypot(x, y);
    if (len > 1) {
      x /= len;
      y /= len;
    }
    this.stick.x = x;
    this.stick.y = y;
    this.knobEl.style.transform = `translate(${x * r * 0.55}px, ${y * r * 0.55}px)`;
  }

  clear(): void {
    this.keyboard.clear();
    this.stick = { x: 0, y: 0, id: -1 };
    this.knobEl.style.transform = '';
    this.touchShoot = this.touchLasso = false;
    this.gamepad.reset();
  }

  dispose(): void {
    this.listeners.abort();
    this.keyboard.dispose();
    this.controllerSource.dispose();
    this.gamepad.dispose();
    this.clear();
  }

  read(): InputFrame {
    const pressed = this.keyboard.read();
    let throttle = (this.keyboard.down('w', 'arrowup') ? 1 : 0) - (this.keyboard.down('s', 'arrowdown') ? 1 : 0);
    let steer = (this.keyboard.down('d', 'arrowright') ? 1 : 0) - (this.keyboard.down('a', 'arrowleft') ? 1 : 0);
    let shoot = pressed.has('shoot');
    let lasso = pressed.has('lasso');
    let pause = pressed.has('pause');
    let confirm = shoot;

    if (this.stick.id !== -1) {
      // Gentle curve so small thumb movements steer precisely.
      const sx = this.stick.x;
      steer = Math.sign(sx) * Math.min(1, Math.abs(sx) ** 1.4 * 1.3);
      throttle = Math.max(-1, Math.min(1, -this.stick.y * 1.25));
    }
    if (this.touchShoot) shoot = confirm = true;
    if (this.touchLasso) lasso = true;

    const frame = this.gamepad.poll();
    for (const pad of frame.players) {
      const a = pad.actions;
      if (Math.abs(a.moveX.value) > Math.abs(steer)) steer = a.moveX.value;
      const padThrottle = a.gas.value - a.brake.value || -a.moveY.value;
      if (Math.abs(padThrottle) > Math.abs(throttle)) throttle = padThrottle;
      if (a.shoot.pressed) shoot = confirm = true;
      if (a.lasso.pressed) lasso = true;
      if (a.pause.pressed) pause = true;
    }

    this.touchShoot = this.touchLasso = false;
    return { throttle, steer, shoot, lasso, pause, confirm };
  }
}