fuzzyworld

explodorama / files / src / ui / gamepad.ts

gamepad.ts

7.9 KB · history · blame

1import { cancelHold, newHold, startHold, stepHold, type HoldEvent } from './padHold'
2import { 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. */
11export 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. */
21export 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
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 */
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
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
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
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
46 /** The pad was unplugged. */
47 gone?(): void
48 /** Reading the pad threw: polling stops (reported here, once). */
49 error?(error: unknown): void
50}
51
52const 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). */
55function 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
66export function createPadInput(
67 targets: PadTargets,
68 getPads: () => ArrayLike<PadLike | null> = () => navigator.getGamepads?.() ?? [],
69 hasFocus: () => boolean = () => document.hasFocus(),
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 = () => '',
72) {
73 let current: { index: number; id: string } | null = null
74 let memory: PadMemory = newPadMemory()
75 let failed = false
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 = ''
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()
85 holdEvents(cancelHold(hold), null)
86 memory = newPadMemory()
87 }
88
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
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.
124 if (current) {
125 forget()
126 targets.gone?.()
127 }
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 }
148 for (const intent of mapPad(snap, memory, nowMs, undefined, padFamily(pad.id))) {
149 targets.active()
150 if (intent.kind === 'dir') targets.dir(intent.dx, intent.dy)
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)
158 } else if (targets.instant?.(intent.face)) {
159 if (!hold.done) holdEvents(cancelHold(hold), null)
160 targets.press(intent.button, intent.face)
161 } else if (startHold(hold, intent.face, nowMs)) {
162 holdButton = intent.button
163 holdContext = context()
164 ringUp = true
165 }
166 }
167 else if (intent.kind === 'look') targets.look(intent.dx, intent.dy)
168 else targets.lookEnd()
169 }
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()
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
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 }
192 targets.error?.(error)
193 }
194 },
195 }
196}