SOURCE / PINNED RELEASE
Made of little things.
Browser Side Quest
- Release
- b9d42f5768e4…
- Author-recorded commit
- c267690145fa…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/browser-side-quest
Archive hash verified: 6d4b90b8a5f328f7…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/**
* One input layer for keyboard, gamepad and the on-screen touch pad. Pointer
* click/tap-to-move (the original control scheme) is wired in app.ts; this adds
* direct movement and an action button on top, all routed through the same
* Game methods as clicks.
*/
import { createGamepadInput, type GamepadBindings } from './gamepad.js';
export interface InputTargets {
/** The running game, or null on menus / while dead. */
playing(): {
stepPlayer(dx: number, dy: number): void;
interact(): void;
stopAttackers(): void;
} | null;
isTyping(): boolean;
toggleChat(): void;
hideWindows(): boolean;
toggleAchievements(): void;
toggleMute(): void;
/** Gamepad A / Enter on menus: play, respawn, rejoin. Returns true if handled. */
confirm(): boolean;
}
type Dir = 'up' | 'down' | 'left' | 'right';
const KEY_DIRS: Record<string, Dir> = {
ArrowUp: 'up',
KeyW: 'up',
ArrowDown: 'down',
KeyS: 'down',
ArrowLeft: 'left',
KeyA: 'left',
ArrowRight: 'right',
KeyD: 'right',
};
const DELTA: Record<Dir, [number, number]> = {
up: [0, -1],
down: [0, 1],
left: [-1, 0],
right: [1, 0],
};
const bindings: GamepadBindings = {
moveX: [{ axis: 0 }, { button: 14, scale: -1 }, { button: 15 }],
moveY: [{ axis: 1 }, { button: 12, scale: -1 }, { button: 13 }],
action: [{ button: 0 }, { button: 7 }],
cancel: [{ button: 1 }],
achievements: [{ button: 3 }],
chat: [{ button: 2 }],
mute: [{ button: 9 }],
};
export class InputLayer {
private readonly keysHeld: Dir[] = [];
private readonly padHeld = new Set<Dir>();
private gamepadDir: Dir | null = null;
private readonly gamepad = createGamepadInput(bindings, { deadZone: 0.25 });
private raf = 0;
private disposed = false;
private readonly cleanups: (() => void)[] = [];
constructor(private readonly targets: InputTargets) {
const onKeyDown = (e: KeyboardEvent): void => this.keyDown(e);
const onKeyUp = (e: KeyboardEvent): void => this.keyUp(e);
const onBlur = (): void => {
this.keysHeld.length = 0;
this.padHeld.clear();
this.gamepadDir = null;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
this.cleanups.push(() => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
});
this.raf = requestAnimationFrame(this.frame);
}
/** Bind the on-screen touch pad buttons. */
bindTouchPad(root: HTMLElement): void {
for (const button of root.querySelectorAll<HTMLButtonElement>('.pad')) {
const dir = button.dataset.dir as Dir | 'action';
const release = (): void => {
button.classList.remove('held');
if (dir !== 'action') this.padHeld.delete(dir);
};
button.addEventListener('pointerdown', (e) => {
e.preventDefault();
button.setPointerCapture(e.pointerId);
button.classList.add('held');
if (dir === 'action') this.targets.playing()?.interact();
else this.padHeld.add(dir);
});
button.addEventListener('pointerup', release);
button.addEventListener('pointercancel', release);
button.addEventListener('lostpointercapture', release);
button.addEventListener('contextmenu', (e) => e.preventDefault());
}
}
private keyDown(e: KeyboardEvent): void {
if (e.isComposing) return;
const typing = this.targets.isTyping();
if (e.key === 'Enter') {
if (!typing && this.targets.confirm()) {
e.preventDefault();
return;
}
// The chat input handles its own Enter (send / close).
if (!typing || (e.target as HTMLElement)?.id === 'chatinput') return;
}
if (typing) return;
const dir = KEY_DIRS[e.code];
if (dir) {
if (this.targets.playing()) e.preventDefault();
if (!this.keysHeld.includes(dir)) this.keysHeld.push(dir);
return;
}
switch (e.code) {
case 'Space':
if (this.targets.playing()) {
e.preventDefault();
if (!e.repeat) this.targets.playing()?.interact();
}
break;
case 'Escape':
this.targets.hideWindows();
this.targets.playing()?.stopAttackers();
break;
}
}
private keyUp(e: KeyboardEvent): void {
const dir = KEY_DIRS[e.code];
if (!dir) return;
const i = this.keysHeld.indexOf(dir);
if (i >= 0) this.keysHeld.splice(i, 1);
}
private frame = (): void => {
if (this.disposed) return;
this.pollGamepad();
const dir =
this.keysHeld[this.keysHeld.length - 1] ??
this.gamepadDir ??
([...this.padHeld][this.padHeld.size - 1] as Dir | undefined) ??
null;
const game = this.targets.playing();
if (dir && game && !this.targets.isTyping()) {
const [dx, dy] = DELTA[dir];
game.stepPlayer(dx, dy);
}
this.raf = requestAnimationFrame(this.frame);
};
private pollGamepad(): void {
const frame = this.gamepad.poll();
if (frame.status !== 'ready') {
this.gamepadDir = null;
return;
}
let dir: Dir | null = null;
for (const pad of frame.players) {
const { moveX, moveY, action, cancel, achievements, chat, mute } = pad.actions;
const ax = moveX.value;
const ay = moveY.value;
if (Math.max(Math.abs(ax), Math.abs(ay)) >= 0.5) {
dir = Math.abs(ax) > Math.abs(ay) ? (ax < 0 ? 'left' : 'right') : ay < 0 ? 'up' : 'down';
}
if (action.pressed && !this.targets.confirm()) this.targets.playing()?.interact();
if (cancel.pressed && !this.targets.hideWindows()) this.targets.playing()?.stopAttackers();
if (achievements.pressed) this.targets.toggleAchievements();
if (chat.pressed) this.targets.toggleChat();
if (mute.pressed) this.targets.toggleMute();
}
this.gamepadDir = dir;
}
dispose(): void {
this.disposed = true;
cancelAnimationFrame(this.raf);
this.gamepad.dispose();
for (const cleanup of this.cleanups) cleanup();
}
}
