Back to Wizard Duel
SOURCE / PINNED RELEASE

Made of little things.

Wizard Duel

Release
48b4ed933a32…
Author-recorded commit
3ed8fcbbf62c…
License
LICENSE
Author’s source reference
nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-86005d75eb4

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

src/game/gamepad-input.ts
import type { ControllerFrame, GamepadAction, GamepadBindings } from './gamepad.js';

/** Standard (Xbox-style) layout: stick or D-pad for moving and runes, A jumps, any trigger or bumper casts, Start pauses. */
export const wizardPadBindings: GamepadBindings = {
  left: [{ axis: 0, scale: -1 }, { button: 14 }],
  right: [{ axis: 0 }, { button: 15 }],
  up: [{ axis: 1, scale: -1 }, { button: 12 }],
  down: [{ axis: 1 }, { button: 13 }],
  jump: [{ button: 0 }],
  cast: [{ button: 4 }, { button: 5 }, { button: 6 }, { button: 7 }],
  start: [{ button: 9 }],
};

/** The parts of the duel a controller talks to. */
export interface PadTarget {
  keyDown(code: string, repeat: boolean): void;
  keyUp(code: string): void;
  pointerStart(): void;
  pausePressed(): void;
  padUnplugged(player: number): void;
  readonly padConnected: [boolean, boolean];
}

type Dir = 'left' | 'right' | 'up' | 'down';
const DIRS: Dir[] = ['left', 'right', 'up', 'down'];
const CODE_NAMES: Record<Dir | 'cast', string> = { left: 'Left', right: 'Right', up: 'Up', down: 'Down', cast: 'Cast' };
/** A stick flick counts once it passes PRESS and must come back under LET_GO before it can count again. */
const PRESS = 0.5;
const LET_GO = 0.3;

interface Slot {
  index: number;
  dir: Dir | null;
  held: Set<string>;
}

/**
 * Turns two controllers into "virtual keys" (Pad1Left, Pad2Cast, ...) so they play exactly like the keyboard.
 * The first controller plugged in is player 1, the second is player 2.
 */
export class PadRouter {
  private readonly slots: Array<Slot | null> = [null, null];

  constructor(private readonly target: PadTarget) {}

  update(frame: ControllerFrame): void {
    for (const index of frame.disconnected) {
      const player = this.slots.findIndex((slot) => slot?.index === index);
      if (player >= 0) this.unplug(player);
    }
    for (const pad of frame.players) {
      let player = this.slots.findIndex((slot) => slot?.index === pad.index);
      if (player < 0) {
        player = this.slots.indexOf(null);
        if (player < 0) continue; // Only two wizards: extra controllers wait their turn.
        this.slots[player] = { index: pad.index, dir: null, held: new Set() };
        this.target.padConnected[player] = true;
      }
      this.drive(player, this.slots[player]!, pad.actions);
    }
  }

  private drive(player: number, slot: Slot, actions: Record<string, GamepadAction>): void {
    const value = (name: string) => Math.max(0, actions[name]?.value ?? 0);
    // Only the strongest direction counts, so a slightly diagonal flick makes one rune, not two.
    if (slot.dir && value(slot.dir) < LET_GO) slot.dir = null;
    let best: Dir | null = null;
    for (const dir of DIRS) if (value(dir) >= PRESS && (!best || value(dir) > value(best))) best = dir;
    if (best && (!slot.dir || value(best) > value(slot.dir))) slot.dir = best;

    const want = new Set<string>();
    if (slot.dir) want.add(this.code(player, slot.dir));
    if (actions.jump?.down) want.add(this.code(player, 'up'));
    if (actions.cast?.down) want.add(this.code(player, 'cast'));

    for (const code of slot.held) if (!want.has(code)) this.target.keyUp(code);
    for (const code of want) if (!slot.held.has(code)) this.target.keyDown(code, false);
    slot.held = want;

    // Start pauses a fight; on the title or winner screen it (or A) begins a duel.
    if (actions.start?.pressed) this.target.pausePressed();
    if (actions.jump?.pressed || actions.start?.pressed) this.target.pointerStart();
  }

  private unplug(player: number): void {
    const slot = this.slots[player]!;
    // Cancel a half-typed spell first, so letting go of the cast key does not fire it.
    this.target.padUnplugged(player);
    for (const code of slot.held) this.target.keyUp(code);
    this.slots[player] = null;
    this.target.padConnected[player] = false;
  }

  private code(player: number, name: Dir | 'cast'): string {
    return `Pad${player + 1}${CODE_NAMES[name]}`;
  }
}