fuzzyworld

explodorama / files / src / ui / padMap.ts

padMap.ts

Blame: the commit that last changed each line · file · history

ebcd0a8f 2026-11-02 Pavel Safronov1import 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. */
11export 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 */
29export type PadButton = 'move' | 'wait' | 'bomb' | 'charge' | 'cancel' | 'info' | 'menu' | 'reset'
30
31export type { Step }
32
33export 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 */
44export 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
64export function newPadMemory(): PadMemory {
65 return { pressed: new Set(), heldDir: '', nextRepeat: 0, stickSector: -1, looking: false, primed: false }
66}
67
92cc4ee3 2026-11-02 Pavel Safronov68/**
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 */
73export const REPEAT_DELAY_MS = 250
74export const REPEAT_MS = 75
ebcd0a8f 2026-11-02 Pavel Safronov75/** 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). */
76export const STICK_ON = 0.5
77export 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 */
83export const SECTOR_HOLD_DEG = 30
84/** Right stick deadzone, radial: a stick resting slightly off centre must not pan the view. */
85export const LOOK_DEADZONE = 0.2
86
92cc4ee3 2026-11-02 Pavel Safronov87// LB backs out; RB and View ask "What is that?". The four face buttons (0..3) are in FACE_BUTTONS, by family.
88const 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. */
91export type FaceIndex = 0 | 1 | 2 | 3
92type 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 */
100const 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`. */
106export 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`. */
111export 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". */
116export 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. */
119export const cancelFace = (family: PadFamily): FaceIndex => faceOf(family, 'wait')
ebcd0a8f 2026-11-02 Pavel Safronov120/**
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 */
124export const LEFT_STICK_ENABLED = false
125const DPAD_UP = 12
126const DPAD_DOWN = 13
127const DPAD_LEFT = 14
128const DPAD_RIGHT = 15
129
130/** The 8 stick directions, clockwise from right (y is down): sector k is centred at k x 45 degrees. */
131const 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. */
134function 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 */
145function 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. */
154function 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 Safronov161export function mapPad(snap: PadSnapshot, mem: PadMemory, nowMs: number, leftStick: boolean = LEFT_STICK_ENABLED, family: PadFamily = 'generic'): PadIntent[] {
ebcd0a8f 2026-11-02 Pavel Safronov162 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 Safronov166 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 Safronov169 for (const [i, button] of Object.entries(BUTTONS)) {
92cc4ee3 2026-11-02 Pavel Safronov170 if (mem.primed && down(Number(i)) && !mem.pressed.has(Number(i))) out.push({ kind: 'press', button })
ebcd0a8f 2026-11-02 Pavel Safronov171 }
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. */
222export type PadFamily = 'xbox' | 'playstation' | 'nintendo' | 'generic'
223
224export 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}