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.
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]}`;
}
}
