explodorama / files / src / ui / padOverlay.ts
padOverlay.ts
| 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 | } |