explodorama / files / src / ui / padOverlay.ts
padOverlay.ts
92cc4ee3 2026-11-02 Pavel Safronov | 1 | import xboxArt from '../../assets/controller-art/padart-xbox.png' |
| 2 | import playstationArt from '../../assets/controller-art/padart-playstation.png' | |
| 3 | import nintendoArt from '../../assets/controller-art/padart-nintendo.png' | |
| 4 | import { spriteUrl } from '../render/sprites' | |
| 5 | import { dpadGlyph, glyphFor, shoulderGlyph, startGlyph, stickGlyph, type DpadDir, type StickDir } from './padGlyph' | |
| 6 | import type { PadFamily } from './padMap' | |
| 7 | import { GLYPH_SCALE, layoutOf, padPlaces, type PadSlot } from './padPlaces' | |
| 8 | ||
| 9 | /** | |
| 10 | * The demo's virtual controller (Q58): a line drawing of a pad (assets/controller-art/, CC BY 4.0) with | |
| 11 | * Kenney's button pictures on it, dim until pressed. The demo and the dialogs' clips show it instead of the tapping finger | |
| 12 | * when a gamepad is in use, so the player sees which button does what. The drawing and | |
| 13 | * the places of the parts are per pad family (padLayouts.ts, padPlaces.ts); the pictures | |
| 14 | * are the ones the action bar already uses. | |
| 15 | */ | |
| 16 | /** | |
| 17 | * How long a pressed button stays lit on the virtual controller (Pavel, 2026-11-02: 0.3 s was too | |
| 18 | * brief to see). The demo and the clips space their presses by this, so two never overlap. | |
| 19 | */ | |
| 20 | export const PAD_LIT_SECONDS = 0.7 | |
| 21 | /** After a d-pad press has let go: the pause in which the cursor has moved and can be seen there, before the next press. */ | |
| 22 | export const PAD_AFTER_SECONDS = 0.3 | |
| 23 | ||
| 24 | export type PadPart = 'up' | 'down' | 'left' | 'right' | 'move' | 'bomb' | 'charge' | 'wait' | 'l1' | 'r1' | 'start' | 'stick' | |
| 25 | ||
| 26 | export interface PadOverlay { | |
| 27 | el: HTMLElement | |
| 28 | /** Which pad's pictures to show; they are swapped at once, even while a button is lit. */ | |
| 29 | setFamily(family: PadFamily): void | |
| 30 | /** Lights `part` (the d-pad and the stick swap to their pressed picture; `stickDir` says which way, default pressed in). Lets go again after `seconds` (default PAD_LIT_SECONDS). */ | |
| 31 | press(part: PadPart, seconds?: number, stickDir?: StickDir): void | |
| 32 | /** Shows a hold's ring (padHold.ts) around the face button `part`, filled `progress` (0..1); null takes it away. */ | |
| 33 | hold(part: PadPart, progress: number | null): void | |
| 34 | /** Lets go of everything at once (and drops pending releases). */ | |
| 35 | releaseAll(): void | |
| 36 | } | |
| 37 | ||
| 38 | const SLOTS: readonly PadSlot[] = ['l1', 'r1', 'start', 'dpad', 'stick', 'charge', 'bomb', 'wait', 'move'] | |
| 39 | type Slot = PadSlot | |
| 40 | /** The four coloured face buttons: the only parts with an idle and a lit look (the rest are white pictures). */ | |
| 41 | const FACE_SLOTS: readonly Slot[] = ['move', 'bomb', 'charge', 'wait'] | |
| 42 | ||
| 43 | const BODY_ART: Record<string, string> = { | |
| 44 | 'padart-xbox.png': xboxArt, | |
| 45 | 'padart-playstation.png': playstationArt, | |
| 46 | 'padart-nintendo.png': nintendoArt, | |
| 47 | } | |
| 48 | ||
| 49 | export function createPadOverlay(size: 'large' | 'compact'): PadOverlay { | |
| 50 | const el = document.createElement('div') | |
| 51 | el.className = `pad-overlay ${size}` | |
| 52 | el.setAttribute('aria-hidden', 'true') | |
| 53 | const imgs = new Map<Slot, HTMLImageElement>() | |
| 54 | for (const slot of SLOTS) { | |
| 55 | const img = document.createElement('img') | |
| 56 | img.className = `pad-part ${slot}` | |
| 57 | // A face button's picture is clipped to its drawn circle (--clip, set by `layout`), so it cannot spill over the outline. | |
| 58 | if (FACE_SLOTS.includes(slot)) img.classList.add('face') | |
| 59 | img.alt = '' | |
| 60 | img.draggable = false | |
| 61 | imgs.set(slot, img) | |
| 62 | el.appendChild(img) | |
| 63 | } | |
| 64 | // The pad's picture (line art: body, sticks and shoulders are all in it), swapped by setFamily; over the pictures, so its lines stay on top. | |
| 65 | const body = document.createElement('img') | |
| 66 | body.className = 'pad-body' | |
| 67 | body.alt = '' | |
| 68 | body.draggable = false | |
| 69 | el.appendChild(body) | |
| 70 | ||
| 71 | // The hold-to-confirm rings (style.css .hold-ring), one behind each face button, hidden until used. | |
| 72 | const rings = new Map<Slot, HTMLElement>() | |
| 73 | for (const slot of FACE_SLOTS) { | |
| 74 | const ring = document.createElement('div') | |
| 75 | ring.className = `hold-ring ${slot}` | |
| 76 | ring.hidden = true | |
| 77 | rings.set(slot, ring) | |
| 78 | el.appendChild(ring) | |
| 79 | } | |
| 80 | ||
| 81 | let family: PadFamily = 'xbox' | |
| 82 | /** What the d-pad and stick show now. */ | |
| 83 | let dpad: DpadDir = 'none' | |
| 84 | let stick: StickDir = 'none' | |
| 85 | const timers = new Map<Slot, number>() | |
| 86 | ||
| 87 | /** Swaps the layout: the body's picture and size, and where each picture and ring sits (percent of the box, so it scales with --pad-size). */ | |
| 88 | const layout = (): void => { | |
| 89 | const l = layoutOf(family) | |
| 90 | el.style.aspectRatio = `${l.width} / ${l.height}` | |
| 91 | body.src = BODY_ART[l.image]! | |
| 92 | const places = padPlaces(family) | |
| 93 | const put = (e: HTMLElement, p: { x: number; y: number; size: number }): void => { | |
| 94 | e.style.left = `${((p.x - p.size / 2) / l.width) * 100}%` | |
| 95 | e.style.top = `${((p.y - p.size / 2) / l.height) * 100}%` | |
| 96 | e.style.width = `${(p.size / l.width) * 100}%` | |
| 97 | } | |
| 98 | for (const slot of SLOTS) put(imgs.get(slot)!, places[slot]) | |
| 99 | for (const slot of FACE_SLOTS) imgs.get(slot)!.style.setProperty('--clip', `${50 / GLYPH_SCALE}%`) | |
| 100 | for (const [slot, ring] of rings) put(ring, places[slot]) | |
| 101 | } | |
| 102 | ||
| 103 | const draw = (): void => { | |
| 104 | const set = (slot: Slot, name: string): void => { | |
| 105 | imgs.get(slot)!.src = spriteUrl(name) | |
| 106 | } | |
| 107 | set('dpad', dpadGlyph(family, dpad)) | |
| 108 | set('stick', stickGlyph(family, stick)) | |
| 109 | for (const b of ['move', 'bomb', 'charge', 'wait'] as const) set(b, glyphFor(family, b)) | |
| 110 | set('l1', shoulderGlyph(family, 'l')) | |
| 111 | set('r1', shoulderGlyph(family, 'r')) | |
| 112 | set('start', startGlyph(family)) | |
| 113 | } | |
| 114 | ||
| 115 | const release = (slot: Slot): void => { | |
| 116 | window.clearTimeout(timers.get(slot)) | |
| 117 | timers.delete(slot) | |
| 118 | imgs.get(slot)!.classList.remove('on') | |
| 119 | if (slot === 'dpad') dpad = 'none' | |
| 120 | if (slot === 'stick') stick = 'none' | |
| 121 | if (slot === 'dpad' || slot === 'stick') draw() | |
| 122 | } | |
| 123 | ||
| 124 | layout() | |
| 125 | draw() | |
| 126 | return { | |
| 127 | el, | |
| 128 | setFamily(f) { | |
| 129 | family = f | |
| 130 | layout() | |
| 131 | draw() | |
| 132 | }, | |
| 133 | press(part, seconds = PAD_LIT_SECONDS, stickDir = 'press') { | |
| 134 | const slot: Slot = part === 'up' || part === 'down' || part === 'left' || part === 'right' ? 'dpad' : part | |
| 135 | window.clearTimeout(timers.get(slot)) | |
| 136 | if (slot === 'dpad') dpad = part as DpadDir | |
| 137 | if (slot === 'stick') stick = stickDir | |
| 138 | if (slot === 'dpad' || slot === 'stick') draw() | |
| 139 | imgs.get(slot)!.classList.add('on') | |
| 140 | timers.set(slot, window.setTimeout(() => release(slot), seconds * 1000)) | |
| 141 | }, | |
| 142 | hold(part, progress) { | |
| 143 | const ring = rings.get(part as Slot) | |
| 144 | if (!ring) return | |
| 145 | ring.hidden = progress === null | |
| 146 | if (progress !== null) { | |
| 147 | ring.style.setProperty('--p', String(progress)) | |
| 148 | ring.classList.toggle('full', progress >= 1) | |
| 149 | } | |
| 150 | }, | |
| 151 | releaseAll() { | |
| 152 | for (const ring of rings.values()) ring.hidden = true | |
| 153 | for (const slot of SLOTS) release(slot) | |
| 154 | }, | |
| 155 | } | |
| 156 | } |