Back to super-platformer
SOURCE / PINNED RELEASE

Made of little things.

super-platformer

Release
b61593abc2c6…
Author-recorded commit
bbd35ca510f6…
License
LICENSE
Author’s source reference
nostr://npub16uawymqtmdg4j6zcl6snt5apzfp4ek2g8lpasnqa75tspdpglcqsgpatvc/wss%3A%2F%2Fgit.napplet.soy%2F/n-01288b1ab38

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

src/controller.ts
import type { InputAction } from './game.js';

/** Merge independent devices so releasing one cannot cancel another. */
export class InputSources {
  private sources = new Map<string, Set<InputAction>>();
  constructor(private target: { press(action: InputAction): void; release(action: InputAction): void }) {}

  set(source: string, actions: Iterable<InputAction>): void {
    const before = new Set([...this.sources.values()].flatMap((set) => [...set]));
    const next = new Set(actions);
    if (next.size) this.sources.set(source, next);
    else this.sources.delete(source);
    const after = new Set([...this.sources.values()].flatMap((set) => [...set]));
    for (const action of before) if (!after.has(action)) this.target.release(action);
    for (const action of after) if (!before.has(action)) this.target.press(action);
  }

  clear(): void {
    for (const source of this.sources.keys()) this.set(source, []);
  }
}

export type ControllerState = 'waiting' | 'connected' | 'unsupported' | 'unavailable';

export class ControllerInput {
  private previous = new Set<number>();
  private activeIndex: number | null = null;
  // Require neutral controls after focus changes / connecting, avoiding accidental actions.
  private armed = false;

  constructor(private input: InputSources, private commands: {
    playing(): boolean;
    primary(): void;
    pause(): void;
    restart(): void;
  }) {}

  reset(): void {
    this.input.set('controller', []);
    this.previous.clear();
    this.armed = false;
    this.activeIndex = null;
  }

  update(pads: readonly (Gamepad | null)[]): ControllerState {
    const supported = pads.filter((pad): pad is Gamepad => !!pad?.connected && pad.mapping === 'standard');
    const pad = supported.find((pad) => pad.index === this.activeIndex) ?? supported[0];
    if (!pad) {
      this.reset();
      return pads.some((pad) => pad?.connected) ? 'unsupported' : 'waiting';
    }
    if (pad.index !== this.activeIndex) {
      this.reset();
      this.activeIndex = pad.index;
    }
    const down = new Set(pad.buttons.flatMap((button, index) => button.pressed ? [index] : []));
    const axis = pad.axes[0] ?? 0;
    if (!this.armed) {
      this.armed = down.size === 0 && Math.abs(axis) < 0.25;
      this.previous = down;
      return 'connected';
    }
    const pressed = (index: number): boolean => down.has(index) && !this.previous.has(index);
    const wasPlaying = this.commands.playing();
    let menuAction = false;
    if (pressed(9)) {
      if (wasPlaying) this.commands.pause();
      else this.commands.primary();
      menuAction = true;
    } else if (!wasPlaying && pressed(0)) {
      this.commands.primary();
      menuAction = true;
    } else if (!wasPlaying && pressed(3)) {
      this.commands.restart();
      menuAction = true;
    }
    const actions: InputAction[] = [];
    if (wasPlaying && !menuAction) {
      if (down.has(14) || axis < -0.25) actions.push('left');
      if (down.has(15) || axis > 0.25) actions.push('right');
      if (down.has(0)) actions.push('jump');
      if (down.has(2)) actions.push('fire');
    }
    this.input.set('controller', actions);
    if (menuAction) this.armed = false;
    this.previous = down;
    return 'connected';
  }
}