fuzzyworld

explodorama / files / src / ui / padOverlay.ts

padOverlay.ts

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

92cc4ee3 2026-11-02 Pavel Safronov1import xboxArt from '../../assets/controller-art/padart-xbox.png'
2import playstationArt from '../../assets/controller-art/padart-playstation.png'
3import nintendoArt from '../../assets/controller-art/padart-nintendo.png'
4import { spriteUrl } from '../render/sprites'
5import { dpadGlyph, glyphFor, shoulderGlyph, startGlyph, stickGlyph, type DpadDir, type StickDir } from './padGlyph'
6import type { PadFamily } from './padMap'
7import { 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 */
20export 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. */
22export const PAD_AFTER_SECONDS = 0.3
23
24export type PadPart = 'up' | 'down' | 'left' | 'right' | 'move' | 'bomb' | 'charge' | 'wait' | 'l1' | 'r1' | 'start' | 'stick'
25
26export 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
38const SLOTS: readonly PadSlot[] = ['l1', 'r1', 'start', 'dpad', 'stick', 'charge', 'bomb', 'wait', 'move']
39type Slot = PadSlot
40/** The four coloured face buttons: the only parts with an idle and a lit look (the rest are white pictures). */
41const FACE_SLOTS: readonly Slot[] = ['move', 'bomb', 'charge', 'wait']
42
43const BODY_ART: Record<string, string> = {
44 'padart-xbox.png': xboxArt,
45 'padart-playstation.png': playstationArt,
46 'padart-nintendo.png': nintendoArt,
47}
48
49export 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}