explodorama / files / src / ui / padDialogMap.ts
padDialogMap.ts
ebcd0a8f 2026-11-02 Pavel Safronov | 1 | /** |
| 2 | * What the controller's buttons do in a dialog or the banner (controller support, stage 3). Pure: | |
| 3 | * padDialog.ts reads the DOM and hands in plain data, so every rule here is unit-tested. | |
| 4 | * | |
| 5 | * In a dialog the footer's buttons sit on face buttons: the primary one on Cross, the others, left | |
| 6 | * to right, on Triangle then Square, and the one that dismisses (Close, Cancel, Back, Got it ...) | |
| 7 | * on Circle. Where that does not fit (more than three, or controls in the body to move between) | |
| 8 | * a d-pad selector moves over everything instead, and Cross presses what it is on. | |
| 9 | */ | |
| 10 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 11 | import { cancelFace, confirmFace, type PadFamily } from './padMap' |
| 12 | ||
ebcd0a8f 2026-11-02 Pavel Safronov | 13 | /** The raw face-button index: 0 Cross/A, 1 Circle/B, 2 Square/X, 3 Triangle/Y. */ |
| 14 | export type Face = 0 | 1 | 2 | 3 | |
| 15 | ||
| 16 | export interface FooterButton { | |
| 17 | label: string | |
| 18 | kind: 'primary' | 'danger' | 'plain' | |
| 19 | } | |
| 20 | ||
| 21 | /** Labels of buttons that just leave the dialog (or go back a step): Circle presses them. */ | |
| 22 | const DISMISS_LABELS = ['Close', 'Cancel', 'Back', 'Got it', 'Keep playing', 'Resume'] | |
| 23 | ||
| 24 | /** Index of the button Circle presses, or -1 (Circle then closes the dialog itself). Only a dialog that can be dismissed has one. */ | |
| 25 | export function dismissIndex(buttons: readonly FooterButton[], dismissable: boolean): number { | |
| 26 | if (!dismissable) return -1 | |
| 27 | return buttons.findIndex((b) => DISMISS_LABELS.includes(b.label)) | |
| 28 | } | |
| 29 | ||
| 30 | /** | |
| 31 | * The face button of each footer button (null: none), or null altogether if they do not fit and | |
92cc4ee3 2026-11-02 Pavel Safronov | 32 | * the selector must be used. `crossTaken`: the confirm button (Cross, A) belongs to something in the body (the shop's Buy). |
ebcd0a8f 2026-11-02 Pavel Safronov | 33 | */ |
92cc4ee3 2026-11-02 Pavel Safronov | 34 | export function assignFaces(buttons: readonly FooterButton[], dismissable: boolean, crossTaken = false, family: PadFamily = 'generic'): (Face | null)[] | null { |
ebcd0a8f 2026-11-02 Pavel Safronov | 35 | const out: (Face | null)[] = buttons.map(() => null) |
| 36 | const dismiss = dismissIndex(buttons, dismissable) | |
92cc4ee3 2026-11-02 Pavel Safronov | 37 | if (dismiss >= 0) out[dismiss] = cancelFace(family) |
ebcd0a8f 2026-11-02 Pavel Safronov | 38 | const rest = buttons.map((_, i) => i).filter((i) => i !== dismiss) |
| 39 | const primary = crossTaken ? -1 : rest.find((i) => buttons[i]!.kind === 'primary') ?? -1 | |
92cc4ee3 2026-11-02 Pavel Safronov | 40 | if (primary >= 0) out[primary] = confirmFace(family) |
ebcd0a8f 2026-11-02 Pavel Safronov | 41 | const spare: Face[] = [3, 2] |
| 42 | for (const i of rest) { | |
| 43 | if (i === primary) continue | |
| 44 | const face = spare.shift() | |
| 45 | if (face === undefined) return null | |
| 46 | out[i] = face | |
| 47 | } | |
| 48 | return out | |
| 49 | } | |
| 50 | ||
| 51 | /** Face buttons on the footer, or a selector over everything: the latter when they do not fit or the body has controls to move between. */ | |
| 52 | export function padMode(faces: (Face | null)[] | null, bodyControls: number): 'faces' | 'selector' { | |
| 53 | return faces && bodyControls === 0 ? 'faces' : 'selector' | |
| 54 | } | |
| 55 | ||
| 56 | export type FaceAction = { kind: 'footer'; index: number } | { kind: 'buy' } | { kind: 'focused' } | { kind: 'close' } | null | |
| 57 | ||
| 58 | /** | |
| 59 | * What face button `face` does. `faces`: the footer's assignment (null in selector mode); | |
| 60 | * `focusedBuy`: the selector is on a shop card with a Buy button. | |
| 61 | */ | |
92cc4ee3 2026-11-02 Pavel Safronov | 62 | export function faceAction(face: Face, faces: (Face | null)[] | null, mode: 'faces' | 'selector', dismissable: boolean, focusedBuy: boolean, dismiss = -1, family: PadFamily = 'generic'): FaceAction { |
| 63 | const confirm = confirmFace(family) | |
| 64 | const cancel = cancelFace(family) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 65 | if (mode === 'selector') { |
92cc4ee3 2026-11-02 Pavel Safronov | 66 | if (face === confirm) return { kind: 'focused' } |
| 67 | if (face !== cancel || !dismissable) return null | |
ebcd0a8f 2026-11-02 Pavel Safronov | 68 | // The footer's own dismissing button if it has one (Back runs its action), else the dialog just closes. |
| 69 | return dismiss >= 0 ? { kind: 'footer', index: dismiss } : { kind: 'close' } | |
| 70 | } | |
92cc4ee3 2026-11-02 Pavel Safronov | 71 | if (face === confirm && focusedBuy) return { kind: 'buy' } |
ebcd0a8f 2026-11-02 Pavel Safronov | 72 | const index = faces?.indexOf(face) ?? -1 |
| 73 | if (index >= 0) return { kind: 'footer', index } | |
92cc4ee3 2026-11-02 Pavel Safronov | 74 | return face === cancel && dismissable ? { kind: 'close' } : null |
| 75 | } | |
| 76 | ||
| 77 | /** Whether a confirm button let go too early shows the "hold to choose" hint: on an item the selector is on, once per dialog (`shown`). */ | |
| 78 | export function showsTapHint(face: Face, mode: 'faces' | 'selector', hasSelection: boolean, shown: boolean, family: PadFamily = 'generic'): boolean { | |
| 79 | return mode === 'selector' && hasSelection && !shown && face === confirmFace(family) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 80 | } |
| 81 | ||
| 82 | /** Where the selector starts in a fresh dialog in selector mode: the footer's first primary button (Resume, Start), else the item marked pressed (a tab), else the first. Returns an index into `items`. */ | |
| 83 | export function startIndex(items: readonly { primary: boolean; pressed: boolean }[]): number { | |
| 84 | const at = items.findIndex((i) => i.primary) | |
| 85 | if (at >= 0) return at | |
| 86 | return Math.max(0, items.findIndex((i) => i.pressed)) | |
| 87 | } | |
| 88 | ||
| 89 | /** After the list was rebuilt: where the selector goes (the same place if it can, else the last; -1 for an empty list). */ | |
| 90 | export function restoreIndex(count: number, previous: number): number { | |
| 91 | if (count <= 0) return -1 | |
| 92 | return Math.max(0, Math.min(previous, count - 1)) | |
| 93 | } | |
| 94 | ||
| 95 | /** The next or previous tab (delta -1 or 1), staying on the first or last. */ | |
| 96 | export function tabStep(count: number, current: number, delta: -1 | 1): number { | |
| 97 | return Math.max(0, Math.min(count - 1, (current < 0 ? 0 : current) + delta)) | |
| 98 | } | |
| 99 | ||
| 100 | /** The right stick scrolls a dialog at this many pixels a second at full push. */ | |
| 101 | export const SCROLL_PX_PER_SECOND = 900 | |
| 102 | ||
| 103 | export function scrollDelta(dy: number, seconds: number): number { | |
| 104 | return dy * SCROLL_PX_PER_SECOND * seconds | |
| 105 | } |