explodorama / files / src / ui / gamepad.ts
gamepad.ts
92cc4ee3 2026-11-02 Pavel Safronov | 1 | import { cancelHold, newHold, startHold, stepHold, type HoldEvent } from './padHold' |
ebcd0a8f 2026-11-02 Pavel Safronov | 2 | import { mapPad, newPadMemory, padFamily, type PadButton, type PadFamily, type PadMemory, type PadSnapshot } from './padMap' |
| 3 | ||
| 4 | /** | |
| 5 | * Reads the first connected gamepad once a frame and hands what the player did to main.ts | |
| 6 | * (controller support, stage 2). The Gamepad API is read through `getPads`, so a test can feed it | |
| 7 | * plain objects; every rule about what a button means lives in padMap.ts. | |
| 8 | */ | |
| 9 | ||
| 10 | /** The part of a `Gamepad` this reads. */ | |
| 11 | export interface PadLike { | |
| 12 | index: number | |
| 13 | id: string | |
| 14 | connected: boolean | |
| 15 | mapping: string | |
| 16 | buttons: ReadonlyArray<{ pressed: boolean }> | |
| 17 | axes: ReadonlyArray<number> | |
| 18 | } | |
| 19 | ||
| 20 | /** What the game does with the pad: main.ts supplies these. */ | |
| 21 | export interface PadTargets { | |
| 22 | /** The d-pad, one step (a held one repeats); the left stick too when it is enabled (padMap.ts). */ | |
| 23 | dir(dx: -1 | 0 | 1, dy: -1 | 0 | 1): void | |
92cc4ee3 2026-11-02 Pavel Safronov | 24 | /** |
| 25 | * `face`: the raw face-button index (0 Cross, 1 Circle, 2 Square, 3 Triangle) when it is one. A | |
| 26 | * face button is delivered once, the moment it has been held for HOLD_SECONDS (padHold.ts), and | |
| 27 | * that button then does nothing more until it is let go; the others at once. | |
| 28 | */ | |
ebcd0a8f 2026-11-02 Pavel Safronov | 29 | press(button: PadButton, face?: 0 | 1 | 2 | 3): void |
| 30 | /** The right stick, while pushed: -1..1, y positive down. Called every poll. */ | |
| 31 | look(dx: number, dy: number): void | |
| 32 | lookEnd(): void | |
720cc9d3 2026-11-03 Pavel Safronov | 33 | /** Whether a face button acts the moment it is pressed, with no hold (Q88: "What is that?", where nothing can hurt). Asked at each press. */ |
| 34 | instant?(face: 0 | 1 | 2 | 3): boolean | |
92cc4ee3 2026-11-02 Pavel Safronov | 35 | /** A face button is being held: how full its ring is, 0..1, every poll. */ |
| 36 | hold?(face: 0 | 1 | 2 | 3, progress: number): void | |
| 37 | /** The ring goes: the button was let go (a completed hold's press came earlier, when the ring filled), the hold was cancelled, or the buttons now act somewhere else. */ | |
| 38 | holdEnd?(): void | |
| 39 | /** A face button was let go before its ring filled (nothing happened): for a hint that it must be held. */ | |
| 40 | tap?(face: 0 | 1 | 2 | 3): void | |
ebcd0a8f 2026-11-02 Pavel Safronov | 41 | /** Any pad input at all (the pad is now what the player is using). */ |
| 42 | active(): void | |
| 43 | toast(title: string, text: string): void | |
| 44 | /** A standard pad connected: which kind, for the pictures on the buttons. */ | |
| 45 | family?(family: PadFamily): void | |
92cc4ee3 2026-11-02 Pavel Safronov | 46 | /** The pad was unplugged. */ |
| 47 | gone?(): void | |
ebcd0a8f 2026-11-02 Pavel Safronov | 48 | /** Reading the pad threw: polling stops (reported here, once). */ |
| 49 | error?(error: unknown): void | |
| 50 | } | |
| 51 | ||
| 52 | const FAMILY_NAMES = { xbox: 'Xbox', playstation: 'PlayStation', nintendo: 'Nintendo', generic: 'Generic' } as const | |
| 53 | ||
| 54 | /** The first connected pad with the standard layout; failing that, the first connected one (only to say it is not recognised). */ | |
| 55 | function choose(pads: ArrayLike<PadLike | null>): PadLike | null { | |
| 56 | let other: PadLike | null = null | |
| 57 | for (let i = 0; i < pads.length; i++) { | |
| 58 | const p = pads[i] | |
| 59 | if (!p?.connected) continue | |
| 60 | if (p.mapping === 'standard') return p | |
| 61 | other ??= p | |
| 62 | } | |
| 63 | return other | |
| 64 | } | |
| 65 | ||
| 66 | export function createPadInput( | |
| 67 | targets: PadTargets, | |
| 68 | getPads: () => ArrayLike<PadLike | null> = () => navigator.getGamepads?.() ?? [], | |
| 69 | hasFocus: () => boolean = () => document.hasFocus(), | |
92cc4ee3 2026-11-02 Pavel Safronov | 70 | /** Where the pad's buttons act now (a dialog, the banner, the board...): a hold does not carry over to another. */ |
| 71 | context: () => string = () => '', | |
ebcd0a8f 2026-11-02 Pavel Safronov | 72 | ) { |
| 73 | let current: { index: number; id: string } | null = null | |
| 74 | let memory: PadMemory = newPadMemory() | |
| 75 | let failed = false | |
92cc4ee3 2026-11-02 Pavel Safronov | 76 | const hold = newHold() |
| 77 | /** What the held face button means (padMap.ts), for when its hold completes. */ | |
| 78 | let holdButton: PadButton | null = null | |
| 79 | /** `context()` when the hold began. */ | |
| 80 | let holdContext = '' | |
ebcd0a8f 2026-11-02 Pavel Safronov | 81 | |
| 82 | /** A fresh memory (a pad that came, left or was swapped; the window lost focus), ending a look that was under way. */ | |
| 83 | function forget(): void { | |
| 84 | if (memory.looking) targets.lookEnd() | |
92cc4ee3 2026-11-02 Pavel Safronov | 85 | holdEvents(cancelHold(hold), null) |
ebcd0a8f 2026-11-02 Pavel Safronov | 86 | memory = newPadMemory() |
| 87 | } | |
| 88 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 89 | /** The ring is up (a hold began and `holdEnd` has not been called since). */ |
| 90 | let ringUp = false | |
| 91 | ||
| 92 | function endRing(): void { | |
| 93 | if (!ringUp) return | |
| 94 | ringUp = false | |
| 95 | targets.holdEnd?.() | |
| 96 | } | |
| 97 | ||
| 98 | /** Passes the hold's events on; `button` is what the held face button means, for a completed one. */ | |
| 99 | function holdEvents(events: HoldEvent[], button: PadButton | null): void { | |
| 100 | for (const e of events) { | |
| 101 | const face = e.face as 0 | 1 | 2 | 3 | |
| 102 | if (e.kind === 'progress') targets.hold?.(face, e.progress) | |
| 103 | // The ring just filled: the action happens now, and the ring stays full until the button is let go. | |
| 104 | else if (e.kind === 'fire') { | |
| 105 | if (button) targets.press(button, face) | |
| 106 | } else endRing() | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | /** The buttons now act somewhere else: a hold not yet complete is cancelled; a used-up button stays used up, only its ring goes. */ | |
| 111 | function checkContext(): void { | |
| 112 | if (hold.face === null || context() === holdContext) return | |
| 113 | if (hold.done) endRing() | |
| 114 | else holdEvents(cancelHold(hold), null) | |
| 115 | } | |
| 116 | ||
ebcd0a8f 2026-11-02 Pavel Safronov | 117 | function read(nowMs: number): void { |
| 118 | // A pad pressed while another window has focus is not for this game. Memory restarts, so a | |
| 119 | // button held across the switch is not a press when the window comes back. | |
| 120 | if (!hasFocus()) return forget() | |
| 121 | const pad = choose(getPads()) | |
| 122 | if (!pad) { | |
| 123 | // Unplugged: forget it, so the next one (or this one again) is announced and starts fresh. | |
92cc4ee3 2026-11-02 Pavel Safronov | 124 | if (current) { |
| 125 | forget() | |
| 126 | targets.gone?.() | |
| 127 | } | |
ebcd0a8f 2026-11-02 Pavel Safronov | 128 | current = null |
| 129 | return | |
| 130 | } | |
| 131 | if (!current || current.index !== pad.index || current.id !== pad.id) { | |
| 132 | current = { index: pad.index, id: pad.id } | |
| 133 | forget() | |
| 134 | if (pad.mapping === 'standard') { | |
| 135 | targets.active() | |
| 136 | targets.family?.(padFamily(pad.id)) | |
| 137 | targets.toast('Controller connected', FAMILY_NAMES[padFamily(pad.id)]) | |
| 138 | } else { | |
| 139 | targets.toast('Controller not recognised', '(non-standard layout)') | |
| 140 | } | |
| 141 | } | |
| 142 | if (pad.mapping !== 'standard') return | |
| 143 | const snap: PadSnapshot = { | |
| 144 | buttons: pad.buttons.map((b) => b.pressed), | |
| 145 | axes: [...pad.axes], | |
| 146 | standard: true, | |
| 147 | } | |
92cc4ee3 2026-11-02 Pavel Safronov | 148 | for (const intent of mapPad(snap, memory, nowMs, undefined, padFamily(pad.id))) { |
ebcd0a8f 2026-11-02 Pavel Safronov | 149 | targets.active() |
| 150 | if (intent.kind === 'dir') targets.dir(intent.dx, intent.dy) | |
92cc4ee3 2026-11-02 Pavel Safronov | 151 | else if (intent.kind === 'press') { |
| 152 | // A face button starts a hold, and acts the moment the ring fills; the rest act at once. | |
| 153 | // An instant button (Start, a bumper) ends a hold under way, so it cannot act on whatever that opened. | |
| 154 | // A button already used up stays used up (until it is let go), whatever else is pressed. | |
| 155 | if (intent.face === undefined) { | |
| 156 | if (!hold.done) holdEvents(cancelHold(hold), null) | |
| 157 | targets.press(intent.button) | |
720cc9d3 2026-11-03 Pavel Safronov | 158 | } else if (targets.instant?.(intent.face)) { |
| 159 | if (!hold.done) holdEvents(cancelHold(hold), null) | |
| 160 | targets.press(intent.button, intent.face) | |
92cc4ee3 2026-11-02 Pavel Safronov | 161 | } else if (startHold(hold, intent.face, nowMs)) { |
| 162 | holdButton = intent.button | |
| 163 | holdContext = context() | |
| 164 | ringUp = true | |
| 165 | } | |
| 166 | } | |
ebcd0a8f 2026-11-02 Pavel Safronov | 167 | else if (intent.kind === 'look') targets.look(intent.dx, intent.dy) |
| 168 | else targets.lookEnd() | |
| 169 | } | |
92cc4ee3 2026-11-02 Pavel Safronov | 170 | checkContext() |
| 171 | const events = stepHold(hold, (face) => snap.buttons[face] === true, nowMs) | |
| 172 | holdEvents(events, holdButton) | |
| 173 | for (const e of events) if (e.kind === 'cancel') targets.tap?.(e.face as 0 | 1 | 2 | 3) | |
| 174 | // The action may have opened a dialog, ended the demo or changed the level: the full ring does not follow it there. | |
| 175 | checkContext() | |
ebcd0a8f 2026-11-02 Pavel Safronov | 176 | } |
| 177 | ||
| 178 | return { | |
| 179 | poll(nowMs: number): void { | |
| 180 | if (failed) return | |
| 181 | try { | |
| 182 | read(nowMs) | |
| 183 | } catch (error) { | |
| 184 | // Never into the game's frame: the pad is given up on, and the keyboard and touch go on. | |
| 185 | failed = true | |
92cc4ee3 2026-11-02 Pavel Safronov | 186 | // The ring must not stay up, nor a hold fire later. |
| 187 | try { | |
| 188 | holdEvents(cancelHold(hold), null) | |
| 189 | } catch { | |
| 190 | // Giving up already. | |
| 191 | } | |
ebcd0a8f 2026-11-02 Pavel Safronov | 192 | targets.error?.(error) |
| 193 | } | |
| 194 | }, | |
| 195 | } | |
| 196 | } |