explodorama / files / src / ui / padMap.ts
padMap.ts
ebcd0a8f 2026-11-02 Pavel Safronov | 1 | import type { Step } from './spatial' |
| 2 | ||
| 3 | /** | |
| 4 | * Turns a gamepad's raw state into what the game does with it (controller support, stage 1). Pure: | |
| 5 | * the caller reads `navigator.getGamepads()` once a frame, copies it into a `PadSnapshot`, and | |
| 6 | * acts on the intents that come back. Keeping the Gamepad API, the DOM and LittleJS out of here | |
| 7 | * is what lets every rule below be unit-tested with plain numbers and a fake clock. | |
| 8 | */ | |
| 9 | ||
| 10 | /** One frame of a pad. Indexes are the W3C "standard" mapping, which browsers give for most pads. */ | |
| 11 | export interface PadSnapshot { | |
| 12 | /** | |
| 13 | * Pressed state per button: 0=A/Cross, 1=B/Circle, 2=X/Square, 3=Y/Triangle, 4=LB/L1, 5=RB/R1, | |
| 14 | * 6=LT, 7=RT, 8=View/Back, 9=Start, 10=L3, 11=R3, 12=d-pad up, 13=down, 14=left, 15=right. | |
| 15 | */ | |
| 16 | buttons: boolean[] | |
| 17 | /** 0,1 = left stick x,y; 2,3 = right stick x,y. Each -1..1, y positive = down. */ | |
| 18 | axes: number[] | |
| 19 | /** `gamepad.mapping === 'standard'`. Not consulted here; the caller decides whether to trust a pad. */ | |
| 20 | standard: boolean | |
| 21 | } | |
| 22 | ||
| 23 | /** | |
| 24 | * What a button means on the board. The four face buttons are Move/Go, Wait, Bomb and Charge (and | |
| 25 | * the intent also carries `face`, their raw index, for dialogs, which map them differently). LB is | |
| 26 | * `cancel` (backs out of the cursor or "What is that?"; the previous tab in a dialog); RB and View | |
| 27 | * are `info` ("What is that?"; the next tab in a dialog). | |
| 28 | */ | |
| 29 | export type PadButton = 'move' | 'wait' | 'bomb' | 'charge' | 'cancel' | 'info' | 'menu' | 'reset' | |
| 30 | ||
| 31 | export type { Step } | |
| 32 | ||
| 33 | export type PadIntent = | |
| 34 | | { kind: 'dir'; dx: Step; dy: Step } | |
| 35 | /** `face`: 0 Cross/A, 1 Circle/B, 2 Square/X, 3 Triangle/Y, for the buttons that are face buttons. */ | |
| 36 | | { kind: 'press'; button: PadButton; face?: 0 | 1 | 2 | 3 } | |
| 37 | | { kind: 'look'; dx: number; dy: number } | |
| 38 | | { kind: 'lookEnd' } | |
| 39 | ||
| 40 | /** | |
| 41 | * What `mapPad` must remember between frames. It is MUTATED IN PLACE by `mapPad` (one pad, one | |
| 42 | * memory; make a fresh one with `newPadMemory()` when a pad connects or is swapped). | |
| 43 | */ | |
| 44 | export interface PadMemory { | |
| 45 | /** Button indexes that were pressed last frame, so a press fires on the rising edge only. */ | |
| 46 | pressed: Set<number> | |
| 47 | /** The direction being held ('dx,dy'), or '' when none; a change of direction counts as a new press. */ | |
| 48 | heldDir: string | |
| 49 | /** When (ms) the held direction fires again. */ | |
| 50 | nextRepeat: number | |
| 51 | /** Left stick: the sector (0..7, see SECTORS) it is counted as pushed into, or -1 when at rest. */ | |
| 52 | stickSector: number | |
| 53 | /** Whether the right stick was outside its deadzone last frame, to send `lookEnd` once on return. */ | |
| 54 | looking: boolean | |
| 55 | /** | |
| 56 | * False until the first `mapPad` call. That call only records what is already down (buttons and | |
| 57 | * direction) and emits no `press` or `dir`: in Chrome a pad is invisible until a button is | |
| 58 | * pressed, and that press, which woke it, must not also act in the game. A direction already | |
| 59 | * held then does not repeat until it is released. | |
| 60 | */ | |
| 61 | primed: boolean | |
| 62 | } | |
| 63 | ||
| 64 | export function newPadMemory(): PadMemory { | |
| 65 | return { pressed: new Set(), heldDir: '', nextRepeat: 0, stickSector: -1, looking: false, primed: false } | |
| 66 | } | |
| 67 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 68 | /** |
| 69 | * Held direction: fires at once, again after this long, then every REPEAT_MS while still held. | |
| 70 | * Quick on purpose (decisions.md 2026-11-02): holding the d-pad is how the cursor crosses the | |
| 71 | * board to a faded tile, with no modifier button. (Menus and dialogs share it.) | |
| 72 | */ | |
| 73 | export const REPEAT_DELAY_MS = 250 | |
| 74 | export const REPEAT_MS = 75 | |
ebcd0a8f 2026-11-02 Pavel Safronov | 75 | /** Left stick counts as pushed once its distance from centre reaches ON, and stays pushed until it falls to OFF (stops flicker at the edge). */ |
| 76 | export const STICK_ON = 0.5 | |
| 77 | export const STICK_OFF = 0.35 | |
| 78 | /** | |
| 79 | * While the stick is held, it changes sector only once it is more than this many degrees from the | |
| 80 | * current sector's centre. A sector's edge is 22.5 degrees out, so this is 7.5 degrees of slack: | |
| 81 | * rolling from right to down, or sitting on a boundary, does not flicker between two directions. | |
| 82 | */ | |
| 83 | export const SECTOR_HOLD_DEG = 30 | |
| 84 | /** Right stick deadzone, radial: a stick resting slightly off centre must not pan the view. */ | |
| 85 | export const LOOK_DEADZONE = 0.2 | |
| 86 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 87 | // LB backs out; RB and View ask "What is that?". The four face buttons (0..3) are in FACE_BUTTONS, by family. |
| 88 | const BUTTONS: Record<number, PadButton> = { 4: 'cancel', 5: 'info', 8: 'info', 9: 'menu', 11: 'reset' } | |
| 89 | ||
| 90 | /** The raw face-button index, W3C standard: 0 bottom, 1 right, 2 left, 3 top. */ | |
| 91 | export type FaceIndex = 0 | 1 | 2 | 3 | |
| 92 | type FaceAction = 'move' | 'wait' | 'bomb' | 'charge' | |
| 93 | ||
| 94 | /** | |
| 95 | * What each face button does, by position: bottom Move/Go (Cross, A), right Wait (Circle, B), left | |
| 96 | * Bomb, top Charge. A Nintendo pad is the one exception (decisions.md 2026-11-02): there A, the | |
| 97 | * "go ahead" button, is on the right and B, "back", is at the bottom, so Move/Go is the right | |
| 98 | * button and Wait the bottom one. Bomb and Charge stay left and top. | |
| 99 | */ | |
| 100 | const FACE_ACTIONS: Record<'default' | 'nintendo', readonly FaceAction[]> = { | |
| 101 | default: ['move', 'wait', 'bomb', 'charge'], | |
| 102 | nintendo: ['wait', 'move', 'bomb', 'charge'], | |
| 103 | } | |
| 104 | ||
| 105 | /** The face-button index that does `action` on a pad of `family`. */ | |
| 106 | export function faceOf(family: PadFamily, action: FaceAction): FaceIndex { | |
| 107 | return FACE_ACTIONS[family === 'nintendo' ? 'nintendo' : 'default'].indexOf(action) as FaceIndex | |
| 108 | } | |
| 109 | ||
| 110 | /** What face button `face` does on a pad of `family`. */ | |
| 111 | export function faceActionOf(family: PadFamily, face: FaceIndex): FaceAction { | |
| 112 | return FACE_ACTIONS[family === 'nintendo' ? 'nintendo' : 'default'][face]! | |
| 113 | } | |
| 114 | ||
| 115 | /** The "confirm" face button: Cross / A on most pads (0), the right button on a Nintendo pad (1, its A). It is Move/Go, a dialog's primary button, and the demo's "hold to play". */ | |
| 116 | export const confirmFace = (family: PadFamily): FaceIndex => faceOf(family, 'move') | |
| 117 | ||
| 118 | /** The "back" face button: Circle / B on most pads (1), the bottom button on a Nintendo pad (0, its B). It is Wait and a dialog's dismiss. */ | |
| 119 | export const cancelFace = (family: PadFamily): FaceIndex => faceOf(family, 'wait') | |
ebcd0a8f 2026-11-02 Pavel Safronov | 120 | /** |
| 121 | * The left stick does nothing for now (Pavel's first pad test, 2026-11-01: it was easy to nudge by | |
| 122 | * accident). Its code stays, off behind this; `mapPad`'s `leftStick` argument lets a test turn it on. | |
| 123 | */ | |
| 124 | export const LEFT_STICK_ENABLED = false | |
| 125 | const DPAD_UP = 12 | |
| 126 | const DPAD_DOWN = 13 | |
| 127 | const DPAD_LEFT = 14 | |
| 128 | const DPAD_RIGHT = 15 | |
| 129 | ||
| 130 | /** The 8 stick directions, clockwise from right (y is down): sector k is centred at k x 45 degrees. */ | |
| 131 | const SECTORS: readonly [Step, Step][] = [[1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1]] | |
| 132 | ||
| 133 | /** Angular distance in degrees between sector `k`'s centre and `angle` (degrees), 0..180. */ | |
| 134 | function sectorOffset(k: number, angle: number): number { | |
| 135 | const d = Math.abs(angle - k * 45) % 360 | |
| 136 | return d > 180 ? 360 - d : d | |
| 137 | } | |
| 138 | ||
| 139 | /** | |
| 140 | * The left stick's sector, or -1 for at rest. The stick is judged as a vector, not per axis (per | |
| 141 | * axis, a diagonal push read as a straight one whenever one axis lagged). Distance from centre | |
| 142 | * has on/off hysteresis; the direction is the nearest of 8 sectors of 45 degrees each, with | |
| 143 | * SECTOR_HOLD_DEG of slack once held. | |
| 144 | */ | |
| 145 | function stickSector(x: number, y: number, was: number): number { | |
| 146 | const m = Math.hypot(x, y) | |
| 147 | if (was === -1 ? m < STICK_ON : m <= STICK_OFF) return -1 | |
| 148 | const angle = (Math.atan2(y, x) * 180) / Math.PI | |
| 149 | if (was !== -1 && sectorOffset(was, angle) <= SECTOR_HOLD_DEG) return was | |
| 150 | return (Math.round(angle / 45) + 8) % 8 | |
| 151 | } | |
| 152 | ||
| 153 | /** Whether direction `key` ('dx,dy') is a part of `of`: a diagonal's axis, so the diagonal was let go of, not a new direction pressed. */ | |
| 154 | function isPartOf(key: string, of: string): boolean { | |
| 155 | if (key === of || of === '') return false | |
| 156 | const [dx, dy] = key.split(',').map(Number) as [number, number] | |
| 157 | const [ox, oy] = of.split(',').map(Number) as [number, number] | |
| 158 | return (dx === 0 || dx === ox) && (dy === 0 || dy === oy) | |
| 159 | } | |
| 160 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 161 | export function mapPad(snap: PadSnapshot, mem: PadMemory, nowMs: number, leftStick: boolean = LEFT_STICK_ENABLED, family: PadFamily = 'generic'): PadIntent[] { |
ebcd0a8f 2026-11-02 Pavel Safronov | 162 | const out: PadIntent[] = [] |
| 163 | const down = (i: number): boolean => snap.buttons[i] === true | |
| 164 | ||
| 165 | // Buttons: only the frame a button goes down counts, so holding one does not repeat it. | |
92cc4ee3 2026-11-02 Pavel Safronov | 166 | for (let i = 0; i < 4; i++) { |
| 167 | if (mem.primed && down(i) && !mem.pressed.has(i)) out.push({ kind: 'press', button: faceActionOf(family, i as FaceIndex), face: i as FaceIndex }) | |
| 168 | } | |
ebcd0a8f 2026-11-02 Pavel Safronov | 169 | for (const [i, button] of Object.entries(BUTTONS)) { |
92cc4ee3 2026-11-02 Pavel Safronov | 170 | if (mem.primed && down(Number(i)) && !mem.pressed.has(Number(i))) out.push({ kind: 'press', button }) |
ebcd0a8f 2026-11-02 Pavel Safronov | 171 | } |
| 172 | mem.pressed = new Set(snap.buttons.flatMap((b, i) => (b ? [i] : []))) | |
| 173 | ||
| 174 | // Direction: the d-pad wins over the left stick. The d-pad can give diagonals (up + right); | |
| 175 | // opposite buttons cancel per axis. The caller drops diagonals the player cannot move on. | |
| 176 | const num = (i: number): number => { | |
| 177 | const v = snap.axes[i] | |
| 178 | return v !== undefined && Number.isFinite(v) ? v : 0 | |
| 179 | } | |
| 180 | mem.stickSector = leftStick ? stickSector(num(0), num(1), mem.stickSector) : -1 | |
| 181 | let dx = ((down(DPAD_RIGHT) ? 1 : 0) - (down(DPAD_LEFT) ? 1 : 0)) as Step | |
| 182 | let dy = ((down(DPAD_DOWN) ? 1 : 0) - (down(DPAD_UP) ? 1 : 0)) as Step | |
| 183 | // Only the d-pad's buttons lift one after the other; a stick rolling between sectors is a real change. | |
| 184 | const fromDpad = dx !== 0 || dy !== 0 | |
| 185 | if (dx === 0 && dy === 0 && mem.stickSector !== -1) [dx, dy] = SECTORS[mem.stickSector] ?? [0, 0] | |
| 186 | const key = dx === 0 && dy === 0 ? '' : `${dx},${dy}` | |
| 187 | if (key === '') { | |
| 188 | mem.heldDir = '' | |
| 189 | } else if (fromDpad && isPartOf(key, mem.heldDir)) { | |
| 190 | // A diagonal letting go: the buttons never lift in the same frame, and the one left must not read as a new step. | |
| 191 | mem.heldDir = key | |
| 192 | mem.nextRepeat = Infinity | |
| 193 | } else if (key !== mem.heldDir) { | |
| 194 | mem.heldDir = key | |
| 195 | // Woken by this frame: remember it, but never repeat a direction nobody pressed in the game. | |
| 196 | mem.nextRepeat = mem.primed ? nowMs + REPEAT_DELAY_MS : Infinity | |
| 197 | if (mem.primed) out.push({ kind: 'dir', dx, dy }) | |
| 198 | } else if (nowMs >= mem.nextRepeat) { | |
| 199 | mem.nextRepeat = nowMs + REPEAT_MS | |
| 200 | out.push({ kind: 'dir', dx, dy }) | |
| 201 | } | |
| 202 | ||
| 203 | // Right stick: continuous. Past the deadzone the distance is rescaled so the edge of the | |
| 204 | // deadzone reads 0 and a full push reads 1 (no jump when it first leaves the deadzone). | |
| 205 | const rx = num(2) | |
| 206 | const ry = num(3) | |
| 207 | const m = Math.hypot(rx, ry) | |
| 208 | if (m > LOOK_DEADZONE) { | |
| 209 | const k = (Math.min(m, 1) - LOOK_DEADZONE) / (1 - LOOK_DEADZONE) / m | |
| 210 | const clamp = (v: number): number => Math.max(-1, Math.min(1, v)) | |
| 211 | out.push({ kind: 'look', dx: clamp(rx * k), dy: clamp(ry * k) }) | |
| 212 | mem.looking = true | |
| 213 | } else if (mem.looking) { | |
| 214 | mem.looking = false | |
| 215 | out.push({ kind: 'lookEnd' }) | |
| 216 | } | |
| 217 | mem.primed = true | |
| 218 | return out | |
| 219 | } | |
| 220 | ||
| 221 | /** Which button glyphs to show: from `Gamepad.id`, which has the vendor and product ids in it. */ | |
| 222 | export type PadFamily = 'xbox' | 'playstation' | 'nintendo' | 'generic' | |
| 223 | ||
| 224 | export function padFamily(id: string): PadFamily { | |
| 225 | const s = id.toLowerCase() | |
| 226 | if (s.includes('nintendo') || s.includes('switch') || s.includes('pro controller') || s.includes('057e')) return 'nintendo' | |
| 227 | if (s.includes('xbox') || s.includes('xinput')) return 'xbox' | |
| 228 | if (s.includes('dualsense') || s.includes('dualshock') || s.includes('playstation') || s.includes('054c')) return 'playstation' | |
| 229 | return 'generic' | |
| 230 | } |