Back to Sickman Survival
SOURCE / PINNED RELEASE

Made of little things.

Sickman Survival

Release
7335c9fdf0a8…
Author-recorded commit
cbd2eeafa70d…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-8804151df7f

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

src/input.ts
import { NO_CONTROLS, type Controls } from './game.ts';
import { createGamepadInput, type ControllerFrame, type GamepadBindings } from './gamepad.ts';
import type { InputSource } from './roster.ts';

type Action = 'left' | 'right' | 'jump' | 'down' | 'crouch';

interface RumbleActuator {
  playEffect?(type: 'dual-rumble', params: {
    duration: number;
    strongMagnitude: number;
    weakMagnitude: number;
  }): Promise<unknown> | undefined;
}

const KEYS: Record<string, Action> = {
  ArrowLeft: 'left',
  KeyA: 'left',
  ArrowRight: 'right',
  KeyD: 'right',
  ArrowUp: 'jump',
  KeyW: 'jump',
  Space: 'jump',
  ArrowDown: 'down',
  KeyS: 'down',
  KeyC: 'crouch',
  ShiftLeft: 'crouch',
  ShiftRight: 'crouch',
};

const PAD_BINDINGS: GamepadBindings = {
  moveX: [{ axis: 0 }, { button: 14, scale: -1 }, { button: 15 }],
  moveY: [{ axis: 1 }, { button: 13 }],
  jump: [{ button: 0 }, { button: 1 }, { button: 12 }],
  crouch: [{ button: 2 }, { button: 6 }],
  start: [{ button: 9 }],
  select: [{ button: 8 }],
};

interface PadState {
  left: boolean;
  right: boolean;
  jump: boolean;
  down: boolean;
  crouch: boolean;
  jumpQueued: boolean;
}

/**
 * Keyboard, multi-touch pads and every gamepad, read either merged (`'solo'`)
 * or one gamepad at a time for split-screen players.
 */
export class Input {
  private readonly keys = new Set<Action>();
  private readonly touches = new Map<number, Action>();
  private readonly pads = new Map<number, PadState>();
  private readonly gamepads = createGamepadInput(PAD_BINDINGS, {
    deadZone: 0.25,
    // Hosts rarely focus the frame before a pad is used; only a hidden page silences pads.
    active: () => !document.hidden,
  });
  private jumpQueued = false;
  private startQueued = false;
  private selectQueued = false;
  private anyWasDown = false;
  private activePad = -1;
  /** Whether any gamepad is connected, as of the last poll. */
  padConnected = false;
  /** Called on the first button press of a gamepad gesture (audio unlock, hints). */
  onGamepadPress: (() => void) | null = null;
  /** Set when any touch input is seen, to reveal the touch pads in "auto" mode. */
  onTouchSeen: (() => void) | null = null;

  attachKeyboard(target: Window): void {
    target.addEventListener('keydown', (event) => {
      const action = KEYS[event.code];
      if (!action) return;
      event.preventDefault();
      if (action === 'jump' && !event.repeat && !this.keys.has('jump')) this.jumpQueued = true;
      this.keys.add(action);
    });
    target.addEventListener('keyup', (event) => {
      const action = KEYS[event.code];
      if (action) this.keys.delete(action);
    });
    target.addEventListener('blur', () => this.reset());
  }

  /** Buttons carry `data-action`; each pointer holds its own button. */
  attachTouchPad(root: HTMLElement): void {
    const release = (event: PointerEvent): void => {
      this.touches.delete(event.pointerId);
    };
    for (const button of root.querySelectorAll<HTMLElement>('[data-action]')) {
      const action = button.dataset.action as Action;
      button.addEventListener('pointerdown', (event) => {
        event.preventDefault();
        button.setPointerCapture(event.pointerId);
        if (action === 'jump' && !this.isHeld('jump')) this.jumpQueued = true;
        this.touches.set(event.pointerId, action);
      });
      button.addEventListener('pointerup', release);
      button.addEventListener('pointercancel', release);
      button.addEventListener('lostpointercapture', release);
      button.addEventListener('contextmenu', (event) => event.preventDefault());
    }
    window.addEventListener('pointerdown', (event) => {
      if (event.pointerType === 'touch') this.onTouchSeen?.();
    }, { capture: true });
  }

  /**
   * Poll every gamepad once per frame: stick or D-pad runs, A/B or D-pad up jumps,
   * stick/D-pad down drops through planks, holding X or the left trigger crouches, Start pauses, starts and restarts, Select/Back toggles sound.
   * Hosts may withhold the gamepad permission from the frame; that never stops the game loop.
   */
  poll(): ControllerFrame {
    const frame = this.gamepads.poll();
    // Any pad that plugs in plays, whatever its mapping: read it in the standard layout.
    for (const controller of frame.players) {
      if (!controller.mapped && frame.connected.includes(controller.index)) {
        this.gamepads.remap(controller.index, PAD_BINDINGS);
      }
    }
    for (const index of frame.disconnected) this.pads.delete(index);
    let any = false;
    for (const c of frame.players) {
      const a = c.actions;
      const state = this.pads.get(c.index) ?? { left: false, right: false, jump: false, down: false, crouch: false, jumpQueued: false };
      const x = a.moveX?.value ?? 0;
      const y = a.moveY?.value ?? 0;
      state.left = x < -0.2;
      state.right = x > 0.2;
      state.down = y > 0.45;
      state.jump = Boolean(a.jump?.down);
      state.crouch = Boolean(a.crouch?.down);
      if (a.jump?.pressed) {
        state.jumpQueued = true;
        this.jumpQueued = true;
      }
      if (a.start?.pressed) this.startQueued = true;
      if (a.select?.pressed) this.selectQueued = true;
      this.pads.set(c.index, state);
      if (c.buttons.some((b) => b >= 0.5)) {
        any = true;
        this.activePad = c.index;
      }
    }
    if (any && !this.anyWasDown) this.onGamepadPress?.();
    this.anyWasDown = any;
    this.padConnected = frame.players.length > 0;
    return frame;
  }

  /** True once per press of Start. */
  takeStart(): boolean {
    const queued = this.startQueued;
    this.startQueued = false;
    return queued;
  }

  /** True once per press of Select/Back. */
  takeSelect(): boolean {
    const queued = this.selectQueued;
    this.selectQueued = false;
    return queued;
  }

  /** Rumble one source's pad, where the browser supports it. `strength` is 0–1. */
  rumble(source: InputSource, strength: number, ms: number): void {
    const index = source === 'solo' ? this.activePad : source;
    if (strength <= 0.02 || index < 0) return;
    try {
      const gp = navigator.getGamepads()[index];
      const actuator = (gp as (Gamepad & { vibrationActuator?: RumbleActuator }) | null)?.vibrationActuator;
      void actuator?.playEffect?.('dual-rumble', {
        duration: ms,
        strongMagnitude: Math.min(1, strength),
        weakMagnitude: Math.min(1, strength * 0.7 + 0.1),
      })?.catch(() => {});
    } catch {
      // No rumble here; nothing to do.
    }
  }

  private isHeld(action: Action): boolean {
    if (this.keys.has(action)) return true;
    for (const pad of this.pads.values()) if (pad[action]) return true;
    for (const held of this.touches.values()) if (held === action) return true;
    return false;
  }

  /** Controls for one source; its jump edge is consumed by this call. */
  controlsFor(source: InputSource): Controls {
    if (source === 'solo') {
      const controls: Controls = {
        left: this.isHeld('left'),
        right: this.isHeld('right'),
        jump: this.isHeld('jump'),
        down: this.isHeld('down'),
        crouch: this.isHeld('crouch'),
        jumpPressed: this.jumpQueued,
      };
      this.jumpQueued = false;
      for (const pad of this.pads.values()) pad.jumpQueued = false;
      return controls;
    }
    const pad = this.pads.get(source);
    if (!pad) return { ...NO_CONTROLS };
    const controls: Controls = { left: pad.left, right: pad.right, jump: pad.jump, down: pad.down, crouch: pad.crouch, jumpPressed: pad.jumpQueued };
    pad.jumpQueued = false;
    return controls;
  }

  /** True once per jump press from any keyboard, touch pad or gamepad (starting a run, resuming). */
  takeAnyJump(): boolean {
    let queued = this.jumpQueued;
    this.jumpQueued = false;
    for (const pad of this.pads.values()) {
      queued ||= pad.jumpQueued;
      pad.jumpQueued = false;
    }
    return queued;
  }

  /** Drop all held input, e.g. on focus loss or pause. */
  reset(): void {
    this.keys.clear();
    this.touches.clear();
    this.jumpQueued = false;
    this.startQueued = false;
    this.selectQueued = false;
    for (const pad of this.pads.values()) pad.jumpQueued = false;
  }
}