fuzzyworld

explodorama / files / src / ui / padDialogMap.ts

padDialogMap.ts

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

ebcd0a8f 2026-11-02 Pavel Safronov1/**
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 Safronov11import { cancelFace, confirmFace, type PadFamily } from './padMap'
12
ebcd0a8f 2026-11-02 Pavel Safronov13/** The raw face-button index: 0 Cross/A, 1 Circle/B, 2 Square/X, 3 Triangle/Y. */
14export type Face = 0 | 1 | 2 | 3
15
16export 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. */
22const 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. */
25export 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 Safronov32 * 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 Safronov33 */
92cc4ee3 2026-11-02 Pavel Safronov34export function assignFaces(buttons: readonly FooterButton[], dismissable: boolean, crossTaken = false, family: PadFamily = 'generic'): (Face | null)[] | null {
ebcd0a8f 2026-11-02 Pavel Safronov35 const out: (Face | null)[] = buttons.map(() => null)
36 const dismiss = dismissIndex(buttons, dismissable)
92cc4ee3 2026-11-02 Pavel Safronov37 if (dismiss >= 0) out[dismiss] = cancelFace(family)
ebcd0a8f 2026-11-02 Pavel Safronov38 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 Safronov40 if (primary >= 0) out[primary] = confirmFace(family)
ebcd0a8f 2026-11-02 Pavel Safronov41 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. */
52export function padMode(faces: (Face | null)[] | null, bodyControls: number): 'faces' | 'selector' {
53 return faces && bodyControls === 0 ? 'faces' : 'selector'
54}
55
56export 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 Safronov62export 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 Safronov65 if (mode === 'selector') {
92cc4ee3 2026-11-02 Pavel Safronov66 if (face === confirm) return { kind: 'focused' }
67 if (face !== cancel || !dismissable) return null
ebcd0a8f 2026-11-02 Pavel Safronov68 // 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 Safronov71 if (face === confirm && focusedBuy) return { kind: 'buy' }
ebcd0a8f 2026-11-02 Pavel Safronov72 const index = faces?.indexOf(face) ?? -1
73 if (index >= 0) return { kind: 'footer', index }
92cc4ee3 2026-11-02 Pavel Safronov74 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`). */
78export 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 Safronov80}
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`. */
83export 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). */
90export 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. */
96export 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. */
101export const SCROLL_PX_PER_SECOND = 900
102
103export function scrollDelta(dy: number, seconds: number): number {
104 return dy * SCROLL_PX_PER_SECOND * seconds
105}