explodorama / files / src / ui / padHold.ts
padHold.ts
92cc4ee3 2026-11-02 Pavel Safronov | 1 | /** |
| 2 | * Hold to confirm (Q59, decisions.md 2026-11-02): a face button on the pad acts only if it is held | |
| 3 | * until a ring has filled, and then at that moment, while it is still down. That button is then | |
| 4 | * used up: it does nothing more, and starts no hold in whatever comes next, until it is let go. | |
| 5 | * Pure, like padMap.ts: the caller feeds it the frame's clock and which button is down, and acts | |
| 6 | * on the events that come back. One hold at a time: while a button is being held (or used up), | |
| 7 | * another face button is ignored. | |
| 8 | */ | |
| 9 | ||
| 10 | /** How long a face button is held for the ring to fill. */ | |
| 11 | export const HOLD_SECONDS = 0.5 | |
| 12 | ||
| 13 | export type HoldEvent = | |
| 14 | /** The ring, every frame while a button is held and its ring is filling: 0..1. */ | |
| 15 | | { kind: 'progress'; face: number; progress: number } | |
| 16 | /** The ring just filled: the action, once. The button stays used up until it is let go (`release`). */ | |
| 17 | | { kind: 'fire'; face: number } | |
| 18 | /** A used-up button was let go: the ring clears, nothing else happens. Ends the hold. */ | |
| 19 | | { kind: 'release'; face: number } | |
| 20 | /** Released too early, or the pad went away: nothing happens. Ends the hold. */ | |
| 21 | | { kind: 'cancel'; face: number } | |
| 22 | ||
| 23 | /** MUTATED IN PLACE by the functions below. `face` is the button being held (or used up), or null. */ | |
| 24 | export interface HoldState { | |
| 25 | face: number | null | |
| 26 | startMs: number | |
| 27 | /** The ring filled and the action fired: the button is used up until it is let go. */ | |
| 28 | done: boolean | |
| 29 | } | |
| 30 | ||
| 31 | export function newHold(): HoldState { | |
| 32 | return { face: null, startMs: 0, done: false } | |
| 33 | } | |
| 34 | ||
| 35 | /** A face button went down at `nowMs`. False (and nothing changes) when another one is being held or used up already. */ | |
| 36 | export function startHold(h: HoldState, face: number, nowMs: number): boolean { | |
| 37 | if (h.face !== null) return false | |
| 38 | h.face = face | |
| 39 | h.startMs = nowMs | |
| 40 | h.done = false | |
| 41 | return true | |
| 42 | } | |
| 43 | ||
| 44 | /** The ring's fill after `nowMs - startMs`, 0..1. */ | |
| 45 | export function holdProgress(startMs: number, nowMs: number): number { | |
| 46 | return Math.max(0, Math.min(1, (nowMs - startMs) / (HOLD_SECONDS * 1000))) | |
| 47 | } | |
| 48 | ||
| 49 | /** One frame: `isDown(face)` says whether the held button still is. */ | |
| 50 | export function stepHold(h: HoldState, isDown: (face: number) => boolean, nowMs: number): HoldEvent[] { | |
| 51 | if (h.face === null) return [] | |
| 52 | const face = h.face | |
| 53 | if (!isDown(face)) { | |
| 54 | h.face = null | |
| 55 | const was = h.done | |
| 56 | h.done = false | |
| 57 | return [was ? { kind: 'release', face } : { kind: 'cancel', face }] | |
| 58 | } | |
| 59 | if (h.done) return [] | |
| 60 | const progress = holdProgress(h.startMs, nowMs) | |
| 61 | if (progress < 1) return [{ kind: 'progress', face, progress }] | |
| 62 | h.done = true | |
| 63 | return [{ kind: 'progress', face, progress }, { kind: 'fire', face }] | |
| 64 | } | |
| 65 | ||
| 66 | /** The pad left, was swapped, or the window lost focus: the hold ends with no action, and a used-up button is free again. */ | |
| 67 | export function cancelHold(h: HoldState): HoldEvent[] { | |
| 68 | if (h.face === null) return [] | |
| 69 | const face = h.face | |
| 70 | h.face = null | |
| 71 | h.done = false | |
| 72 | return [{ kind: 'cancel', face }] | |
| 73 | } |