explodorama / files / src / ui / padDialogMap.ts
padDialogMap.ts
| 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 | |
| 11 | import { cancelFace, confirmFace, type PadFamily } from './padMap' |
| 12 | |
| 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 |
| 32 | * the selector must be used. `crossTaken`: the confirm button (Cross, A) belongs to something in the body (the shop's Buy). |
| 33 | */ |
| 34 | export function assignFaces(buttons: readonly FooterButton[], dismissable: boolean, crossTaken = false, family: PadFamily = 'generic'): (Face | null)[] | null { |
| 35 | const out: (Face | null)[] = buttons.map(() => null) |
| 36 | const dismiss = dismissIndex(buttons, dismissable) |
| 37 | if (dismiss >= 0) out[dismiss] = cancelFace(family) |
| 38 | const rest = buttons.map((_, i) => i).filter((i) => i !== dismiss) |
| 39 | const primary = crossTaken ? -1 : rest.find((i) => buttons[i]!.kind === 'primary') ?? -1 |
| 40 | if (primary >= 0) out[primary] = confirmFace(family) |
| 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 | */ |
| 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) |
| 65 | if (mode === 'selector') { |
| 66 | if (face === confirm) return { kind: 'focused' } |
| 67 | if (face !== cancel || !dismissable) return null |
| 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 | } |
| 71 | if (face === confirm && focusedBuy) return { kind: 'buy' } |
| 72 | const index = faces?.indexOf(face) ?? -1 |
| 73 | if (index >= 0) return { kind: 'footer', index } |
| 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) |
| 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 | } |