Add Xbox/gamepad support via Gamepad API — left stick and D-pad to walk, A to confirm/talk, B to advance

This commit is contained in:
justin committed 2026-05-14 15:04:10 -07:00
1 parent 22b0dc0713
commit 10c4147483
1 file changed
+108 -1
+108 -1
View File
@@ -256,9 +256,11 @@ class Game {
this._walkLoop = null;
this._introClick = null;
this._introKey = null;
this._gpLoop = null;
this._bindController();
this._bindGlobalKeys();
this._bindGamepad();
this.boot();
}
@@ -583,8 +585,113 @@ class Game {
}
/* ══════════════════════════════════════════════════════════
CONTROLLER VISIBILITY
XBOX / GAMEPAD SUPPORT (Gamepad API)
Polls every animation frame. Works with Xbox, PS, and most
USB/Bluetooth gamepads that the browser recognises.
Xbox mapping:
Axes 0/1 = left stick X/Y
Button 0 = A (confirm / talk)
Button 1 = B (talk / back)
Button 12 = D-pad up
Button 13 = D-pad down
Button 14 = D-pad left
Button 15 = D-pad right
Button 9 = Start (confirm)
══════════════════════════════════════════════════════════ */
_bindGamepad() {
/* Track previous button state to detect press edges */
this._gpPrev = {};
this._gpActive = false;
window.addEventListener('gamepadconnected', (e) => {
console.log('Gamepad connected:', e.gamepad.id);
this._gpActive = true;
if (!this._gpLoop) this._startGpLoop();
});
window.addEventListener('gamepaddisconnected', () => {
const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
this._gpActive = [...gamepads].some(g => g);
});
}
_startGpLoop() {
const poll = () => {
this._gpLoop = requestAnimationFrame(poll);
this._pollGamepad();
};
this._gpLoop = requestAnimationFrame(poll);
}
_pollGamepad() {
const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
const gp = [...gamepads].find(g => g);
if (!gp) return;
const DEAD = 0.25; /* analog stick deadzone */
const axes = gp.axes;
const btns = gp.buttons;
/* ── Directional input ─────────────────────────────── */
const gpDirs = {
up: (axes[1] < -DEAD) || (btns[12]?.pressed),
down: (axes[1] > DEAD) || (btns[13]?.pressed),
left: (axes[0] < -DEAD) || (btns[14]?.pressed),
right: (axes[0] > DEAD) || (btns[15]?.pressed),
};
const s = this.state.screen;
Object.entries(gpDirs).forEach(([dir, active]) => {
const key = `axis_${dir}`;
if (active && !this._gpPrev[key]) {
/* Pressed this frame */
if (s === 'map' || s === 'battle') {
this._heldKeys.add(dir);
if (s === 'map' && !this._walkLoop) this._startWalkLoop();
if (s === 'battle') this._moveCursor(dir);
}
} else if (!active && this._gpPrev[key]) {
/* Released this frame */
this._heldKeys.delete(dir);
if (this._heldKeys.size === 0) this._stopWalkLoop();
}
this._gpPrev[key] = active;
});
/* ── Button press edges (fire once on press) ───────── */
const pressedNow = (i) => btns[i]?.pressed;
const wasPressed = (i) => !!this._gpPrev[`btn_${i}`];
const justPressed = (i) => pressedNow(i) && !wasPressed(i);
/* A button (0) or Start (9) = confirm / talk */
if (justPressed(0) || justPressed(9)) {
this._pressA();
}
/* B button (1) = back / talk */
if (justPressed(1)) {
this._pressB();
}
/* Quick-pick answers with X=2, Y=3, LB=4, RB=5 on battle screen */
if (s === 'battle' && !this.state.answering) {
const quickMap = { 2:0, 3:1, 4:2, 5:3 };
Object.entries(quickMap).forEach(([btn, idx]) => {
if (justPressed(+btn)) {
this.state.cursor = idx;
this._renderCursor();
setTimeout(() => this._confirmCursor(), 80);
}
});
}
/* Store current button states for next frame */
for (let i = 0; i < btns.length; i++) {
this._gpPrev[`btn_${i}`] = btns[i]?.pressed;
}
}
_showController(visible) {
const ctrl = document.getElementById('gba-controller');
if (!ctrl) return;