fuzzyworld

explodorama / files / src / ui / controls.ts

controls.ts

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

99869630 2026-10-23 Pavel Safronov1import { canPick } from '../sim/picks'
720cc9d3 2026-11-03 Pavel Safronov2import { CLEAR_BONUS, PAR_BONUS, START_LIVES, turnNumber } from '../sim/state'
52d68b59 2026-10-24 Pavel Safronov3import { CHARGE_IDS, type ActionId, type GameState, type PowerUpKind, type Powers } from '../sim/types'
99869630 2026-10-23 Pavel Safronov4import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
0580e85e 2026-10-24 Pavel Safronov5import { CHARGE_TEXT, chargeLeft, deathText, type HudItem } from './info'
ebcd0a8f 2026-11-02 Pavel Safronov6import { glyphFor, type GlyphButton } from './padGlyph'
92cc4ee3 2026-11-02 Pavel Safronov7import { faceOf, type PadFamily } from './padMap'
0580e85e 2026-10-24 Pavel Safronov8import { openScores } from './scores'
ea380ee8 2026-10-22 Pavel Safronov9import questionIcon from '../../assets/kenney-icons/question.png'
720cc9d3 2026-11-03 Pavel Safronov10import cancelIcon from '../../assets/kenney-icons/cancel.png'
5551eb37 2026-10-20 Pavel Safronov11
c37a2b0b 2026-10-20 Pavel Safronov12/** The HTML around the board: HUD, action bar, and the banner shown between attempts and levels. */
5551eb37 2026-10-20 Pavel Safronov13export interface Handlers {
99869630 2026-10-23 Pavel Safronov14 /**
15 * An action button was pressed: `id` is the first of its actions that can be picked now, and
16 * `pressed` says whether one of them is already picked (so the press cancels it).
17 */
18 pick(id: ActionId, pressed: boolean): void
0580e85e 2026-10-24 Pavel Safronov19 /**
20 * The banner's buttons: continue after losing a life or clearing a level; after game over,
21 * the same dungeon again (`second` false) or a new one (`second` true); a new run after the ending.
22 */
23 banner(second: boolean): void
a4fab0fb 2026-10-21 Pavel Safronov24 /** A top-bar item was tapped: show what it means. */
25 info(item: HudItem): void
99869630 2026-10-23 Pavel Safronov26 /** The "What is that?" button: the next tap on the board (or an action button) explains it. */
ea380ee8 2026-10-22 Pavel Safronov27 inspect(): void
720cc9d3 2026-11-03 Pavel Safronov28 /** The Cancel button (shown while an action is picked or auto-move has a destination): puts it away, as Esc does. */
29 cancel(): void
a4fab0fb 2026-10-21 Pavel Safronov30 menu(): void
5551eb37 2026-10-20 Pavel Safronov31}
32
99869630 2026-10-23 Pavel Safronov33/**
34 * The action bar: as few buttons as possible, icons only (user request, 2026-10-22). Each stands
35 * for a group of actions: Move also runs, teleports, kicks and steps diagonally, and Bomb also
d3e4ed26 2026-10-25 Pavel Safronov36 * throws and detonates (sim/picks.ts). The charge button (2026-10-24: one charge at a time, used
37 * on purpose) shows the charge held, with what is left of it, or an empty slot.
99869630 2026-10-23 Pavel Safronov38 */
39interface ButtonDef {
40 id: ActionId
41 label: string
42 icon: string
43 key: string
44 /** The actions it stands for; the first one that can be picked is picked. */
45 group: readonly ActionId[]
c37a2b0b 2026-10-20 Pavel Safronov46}
644cfd0b 2026-10-20 Pavel Safronov47
99869630 2026-10-23 Pavel Safronov48const BUTTONS: ButtonDef[] = [
fcf44f32 2026-10-25 Pavel Safronov49 { id: 'move', label: 'Move', icon: 'icon_move', key: 'M', group: ['move', 'run', 'kick', 'diagonal', 'slide'] },
d3e4ed26 2026-10-25 Pavel Safronov50 { id: 'bomb', label: 'Bomb', icon: 'bomb', key: 'B', group: ['bomb', 'throw', 'rpg', 'detonate'] },
5f066960 2026-10-26 Pavel Safronov51 { id: 'charged', label: 'Charge', icon: 'bomb', key: 'V', group: ['charged', 'icbm', 'freeze', 'decoy', 'teleport'] },
99869630 2026-10-23 Pavel Safronov52 { id: 'wait', label: 'Wait', icon: 'icon_wait', key: 'Space', group: ['wait'] },
53]
54
27f24fed 2026-10-20 Pavel Safronov55/** The passes shown in the HUD, with their power-up and name. */
56const PASSES: [keyof Powers, PowerUpKind, string][] = [
99869630 2026-10-23 Pavel Safronov57 ['run', 'run', 'Run'],
58 ['kick', 'kick', 'Kick'],
59 ['throw', 'throw', 'Throw'],
52d68b59 2026-10-24 Pavel Safronov60 ['diagonal', 'diagonal', 'Diagonal'],
27f24fed 2026-10-20 Pavel Safronov61 ['detonate', 'detonator', 'Detonator'],
ebcd0a8f 2026-11-02 Pavel Safronov62 ['wallPass', 'wallPass', 'Crate Pass'],
27f24fed 2026-10-20 Pavel Safronov63 ['bombPass', 'bombPass', 'Bomb Pass'],
7f242d36 2026-10-25 Pavel Safronov64 ['rpg', 'rpg', 'RPG'],
65 ['proximity', 'proximity', 'Proximity alert'],
66 ['slide', 'slide', 'Slide'],
27f24fed 2026-10-20 Pavel Safronov67]
68
a4fab0fb 2026-10-21 Pavel Safronov69/** A top-bar item: a button that opens its info (`data-info`, see ui/info.ts). */
70function item(info: HudItem, html: string): string {
71 return `<button type="button" class="hud-item" data-info="${info}">${html}</button>`
72}
73
ea380ee8 2026-10-22 Pavel Safronov74/**
75 * Fill a top-bar slot, keeping the items that didn't change. An item that did (a number went up
76 * or down, a heart emptied, a pass arrived) is replaced and bumps (style.css), except on the
77 * first fill.
78 */
79function show(slot: HTMLElement, html: string): void {
80 const template = document.createElement('template')
81 template.innerHTML = html
82 const first = !slot.dataset.filled
83 slot.dataset.filled = 'yes'
84 const old = new Map([...slot.children].map((c) => [(c as HTMLElement).dataset.was, c]))
85 slot.replaceChildren(
86 ...[...template.content.children].map((c) => {
87 const was = c.outerHTML
88 const kept = old.get(was)
89 if (kept) return kept
90 ;(c as HTMLElement).dataset.was = was
52d68b59 2026-10-24 Pavel Safronov91 if (!first) {
92 c.classList.add('bump')
93 // Off again once it has played: every later update re-attaches the kept items, which
94 // would otherwise start the bump over each time.
95 c.addEventListener('animationend', () => c.classList.remove('bump'), { once: true })
96 }
ea380ee8 2026-10-22 Pavel Safronov97 return c
98 }),
99 )
100}
101
644cfd0b 2026-10-20 Pavel Safronov102function icon(name: string, className = 'icon'): string {
99869630 2026-10-23 Pavel Safronov103 const mono = MONO_ICONS.has(name) ? ' mono' : ''
104 return `<img class="${className}${mono}" src="${spriteUrl(name)}" alt="" draggable="false">`
644cfd0b 2026-10-20 Pavel Safronov105}
106
107/** A number in the pack's HUD digits. The images are decoration; `label` is what screen readers get. */
108function number(n: number, label: string): string {
109 return `<span class="sr-only">${label}</span>` + [...String(n)].map((d) => icon(`hud_character_${d}`, 'digit')).join('')
110}
5551eb37 2026-10-20 Pavel Safronov111
112export function createControls(handlers: Handlers) {
113 const bar = document.getElementById('actions')!
ebcd0a8f 2026-11-02 Pavel Safronov114 // The controller's picture for each button, shown only while the pad is in use (style.css `.pad-glyph`).
115 let family: PadFamily = 'generic'
116 const glyphButton = (def: ButtonDef): GlyphButton => (def.id === 'charged' ? 'charge' : (def.id as GlyphButton))
92cc4ee3 2026-11-02 Pavel Safronov117 // data-face (0 bottom, 1 right, 2 left, 3 top: the raw button, so it depends on the kind of pad) is where the hold-to-confirm ring finds it (holdRing.ts).
118 const glyph = (def: ButtonDef): string => `<img class="pad-glyph" data-face="${faceOf(family, glyphButton(def))}" src="${spriteUrl(glyphFor(family, glyphButton(def)))}" alt="" draggable="false">`
99869630 2026-10-23 Pavel Safronov119 const buttons = new Map<ButtonDef, HTMLButtonElement>()
120 for (const def of BUTTONS) {
5551eb37 2026-10-20 Pavel Safronov121 const b = document.createElement('button')
122 b.type = 'button'
99869630 2026-10-23 Pavel Safronov123 b.dataset.action = def.id
124 b.title = def.label
125 b.setAttribute('aria-label', def.label)
ebcd0a8f 2026-11-02 Pavel Safronov126 b.innerHTML = `${icon(def.icon)}<kbd>${def.key}</kbd>${glyph(def)}`
99869630 2026-10-23 Pavel Safronov127 b.addEventListener('click', () => {
128 const id = def.group.find((a) => canPick(current!, a)) ?? def.id
129 handlers.pick(id, !!currentSelected && def.group.includes(currentSelected))
130 })
5551eb37 2026-10-20 Pavel Safronov131 bar.append(b)
99869630 2026-10-23 Pavel Safronov132 buttons.set(def, b)
133 }
134
720cc9d3 2026-11-03 Pavel Safronov135 // Cancel (Q89, user report: touch had no way to put away a picked action or a walk): a small round
136 // button over the bar's top right, only while there is something to cancel.
137 const cancelButton = document.createElement('button')
138 cancelButton.type = 'button'
139 cancelButton.className = 'cancel'
140 cancelButton.hidden = true
141 cancelButton.title = 'Cancel'
142 cancelButton.setAttribute('aria-label', 'Cancel')
143 cancelButton.innerHTML = `<img src="${cancelIcon}" alt="" draggable="false">`
144 cancelButton.addEventListener('click', () => handlers.cancel())
145 bar.append(cancelButton)
146
99869630 2026-10-23 Pavel Safronov147 let current: GameState | null = null
0580e85e 2026-10-24 Pavel Safronov148 let bannerNote = ''
99869630 2026-10-23 Pavel Safronov149 let currentSelected: ActionId | null = null
150
151 // "What is that?": a round button next to the menu, top right.
152 const inspectButton = document.getElementById('info-button') as HTMLButtonElement
153 inspectButton.innerHTML = `<img src="${questionIcon}" alt="" draggable="false">`
ea380ee8 2026-10-22 Pavel Safronov154 inspectButton.addEventListener('click', () => handlers.inspect())
155
5551eb37 2026-10-20 Pavel Safronov156 const banner = document.getElementById('banner')!
157 const bannerText = document.getElementById('banner-text')!
644cfd0b 2026-10-20 Pavel Safronov158 const bannerIcon = document.getElementById('banner-icon') as HTMLImageElement
5551eb37 2026-10-20 Pavel Safronov159 const bannerButton = document.getElementById('banner-button') as HTMLButtonElement
0580e85e 2026-10-24 Pavel Safronov160 bannerButton.addEventListener('click', () => handlers.banner(false))
161 const bannerButton2 = document.getElementById('banner-button-2') as HTMLButtonElement
162 bannerButton2.addEventListener('click', () => handlers.banner(true))
163 // At the end of a run the banner covers the menu, so the board is reachable from it too.
164 const bannerScores = document.getElementById('banner-scores') as HTMLButtonElement
165 bannerScores.addEventListener('click', () => openScores())
5551eb37 2026-10-20 Pavel Safronov166
a4fab0fb 2026-10-21 Pavel Safronov167 const hudEl = document.getElementById('hud')!
168 hudEl.addEventListener('click', (e) => {
169 const b = (e.target as Element).closest<HTMLElement>('[data-info]')
170 if (b) handlers.info(b.dataset.info as HudItem)
171 })
172 document.getElementById('menu-button')!.addEventListener('click', () => handlers.menu())
173
5551eb37 2026-10-20 Pavel Safronov174 const hud = {
175 lives: document.getElementById('hud-lives')!,
176 level: document.getElementById('hud-level')!,
177 turn: document.getElementById('hud-turn')!,
c37a2b0b 2026-10-20 Pavel Safronov178 enemies: document.getElementById('hud-enemies')!,
179 coins: document.getElementById('hud-coins')!,
5551eb37 2026-10-20 Pavel Safronov180 stats: document.getElementById('hud-stats')!,
52d68b59 2026-10-24 Pavel Safronov181 extras: document.getElementById('hud-extras')!,
beb9f2b7 2026-10-21 Pavel Safronov182 modes: document.getElementById('hud-modes')!,
5551eb37 2026-10-20 Pavel Safronov183 }
184
185 return {
e3d8dc49 2026-10-27 Pavel Safronov186 /**
187 * `autoMove`: a destination is set, so Move takes one step toward it. `inspecting`: "What is
188 * that?" is on. `deadlyWait`: waiting would cost a life this turn.
189 */
190 update(s: GameState, selected: ActionId | null, autoMove: boolean, inspecting: boolean, deadlyWait = false): void {
ea380ee8 2026-10-22 Pavel Safronov191 inspectButton.setAttribute('aria-pressed', String(inspecting))
720cc9d3 2026-11-03 Pavel Safronov192 cancelButton.hidden = !(s.status === 'playing' && !inspecting && (selected !== null || autoMove))
ea380ee8 2026-10-22 Pavel Safronov193 inspectButton.disabled = s.status !== 'playing'
99869630 2026-10-23 Pavel Safronov194 current = s
195 currentSelected = selected
196 for (const [def, b] of buttons) {
d3e4ed26 2026-10-25 Pavel Safronov197 // With "What is that?" on, every button can be tapped, to be explained (even one that can't act now).
198 b.disabled = !inspecting && !def.group.some((id) => canPick(s, id))
99869630 2026-10-23 Pavel Safronov199 b.setAttribute('aria-pressed', String(!!selected && def.group.includes(selected)))
d3e4ed26 2026-10-25 Pavel Safronov200 if (def.id === 'charged') {
201 // Always there (user request, 2026-10-25): the charge held, with its icon and what is left;
202 // with none, an empty slot. Greyed out while it can't be used (none held, a Shield, which
203 // works by itself, or no room for another bomb). "What is that?" on it explains it all.
204 const kind = s.charge?.kind
205 const shown = kind ? `${kind}:${s.charge!.left}` : 'empty'
206 if (b.dataset.kind !== shown) {
207 b.dataset.kind = shown
208 const name = kind ? CHARGE_TEXT[kind].name : 'Charge'
209 b.innerHTML = kind
ebcd0a8f 2026-11-02 Pavel Safronov210 ? `${icon(CHARGE_ICONS[kind])}<span class="count">${s.charge!.left}</span><kbd>${def.key}</kbd>${glyph(def)}`
211 : `<span class="slot" aria-hidden="true"></span><kbd>${def.key}</kbd>${glyph(def)}`
d3e4ed26 2026-10-25 Pavel Safronov212 b.title = kind ? name : 'Charge: none carried'
213 b.setAttribute('aria-label', kind ? `${name} charge, ${s.charge!.left} left` : 'Charge: none carried')
214 }
215 }
e3d8dc49 2026-10-27 Pavel Safronov216 if (def.id === 'wait') {
217 // Red when it would cost a life (user request, 2026-10-26). It still waits: with
218 // nothing else to do, that is the player's to choose.
219 b.classList.toggle('deadly', deadlyWait)
220 b.title = deadlyWait ? 'Wait (deadly!)' : def.label
221 b.setAttribute('aria-label', deadlyWait ? 'Wait: you would die' : def.label)
222 }
99869630 2026-10-23 Pavel Safronov223 if (def.id === 'move') {
52d68b59 2026-10-24 Pavel Safronov224 // Auto-move: Move has become Go, one step toward the destination per press. It keeps
225 // the walk icon (user request, 2026-10-23: it used to turn into a flag); a green
226 // border says it will walk.
99869630 2026-10-23 Pavel Safronov227 b.setAttribute('aria-label', autoMove ? 'Go' : def.label)
228 b.title = autoMove ? 'Go' : def.label
82adee18 2026-10-20 Pavel Safronov229 b.classList.toggle('auto', autoMove)
230 }
5551eb37 2026-10-20 Pavel Safronov231 }
644cfd0b 2026-10-20 Pavel Safronov232 // Lives lost from the starting three show as empty hearts; extra lives add full ones.
233 const hearts = Array.from({ length: Math.max(s.lives, START_LIVES) }, (_, i) =>
234 icon(i < s.lives ? 'hud_heart' : 'hud_heart_empty'),
235 )
00ad7d0a 2026-10-24 Pavel Safronov236 // The run's modes, as small tags, when it has any.
237 const tags = [s.options.dev && 'Dev', s.options.immortal && 'Immortal'].filter(Boolean)
ea380ee8 2026-10-22 Pavel Safronov238 show(hud.modes, tags.length ? item('modes', tags.map((t) => `<span class="tag">${t}</span>`).join('')) : '')
239 show(hud.lives, item('lives', `<span class="sr-only">${s.lives} lives</span>${hearts.join('')}`))
240 show(hud.level, item('level', icon('flag_green_a') + number(s.level, 'Level ')))
241 show(hud.enemies, item('enemies', icon('slime_normal_rest') + number(s.enemies.length, 'Enemies left ')))
242 show(hud.coins, item('coins', icon('hud_coin') + number(s.coins, 'Coins ')))
720cc9d3 2026-11-03 Pavel Safronov243 // The turn the player is on (from 1), and the par to beat for the bonus.
ea380ee8 2026-10-22 Pavel Safronov244 show(
245 hud.turn,
720cc9d3 2026-11-03 Pavel Safronov246 item('turn', `<small aria-hidden="true">Turn</small>` + number(turnNumber(s), 'Turn ') + `<small class="par">/${s.par}</small>`),
a4fab0fb 2026-10-21 Pavel Safronov247 )
27f24fed 2026-10-20 Pavel Safronov248 // Bomb limit, blast size and fuse, then a small icon for each pass held and the Star's turns.
249 const passes = PASSES.filter(([p]) => s.powers[p])
a4fab0fb 2026-10-21 Pavel Safronov250 .map(([p, kind, name]) => item(p, `<span class="sr-only">${name}</span>${icon(POWER_UP_ICONS[kind], 'icon small')}`))
27f24fed 2026-10-20 Pavel Safronov251 .join('')
a4fab0fb 2026-10-21 Pavel Safronov252 const star = s.star > 0 ? item('star', icon('star', 'icon small') + number(s.star, 'Star for ')) : ''
d3e4ed26 2026-10-25 Pavel Safronov253 // The charge held, with what is left, and a Freeze or Decoy at work, with its turns.
52d68b59 2026-10-24 Pavel Safronov254 const charges = CHARGE_IDS.map((id) => {
255 const left = chargeLeft(s, id)
256 if (!left) return ''
257 const name = CHARGE_TEXT[id].name
258 return item(id, icon(CHARGE_ICONS[id], 'icon small') + (left === true ? `<span class="sr-only">${name}</span>` : number(left, `${name} `)))
259 }).join('')
ea380ee8 2026-10-22 Pavel Safronov260 show(
261 hud.stats,
a4fab0fb 2026-10-21 Pavel Safronov262 item('bombs', icon('bomb') + number(s.stats.capacity, 'Bombs ')) +
ea380ee8 2026-10-22 Pavel Safronov263 item('fire', icon('fireball') + number(s.stats.fire, 'Blast ')) +
52d68b59 2026-10-24 Pavel Safronov264 item('fuse', icon('icon_fuse') + number(s.stats.fuse, 'Fuse ')),
ea380ee8 2026-10-22 Pavel Safronov265 )
52d68b59 2026-10-24 Pavel Safronov266 // Their own row, which keeps its place even while empty, so the board never moves.
267 show(hud.extras, passes + star + charges)
5551eb37 2026-10-20 Pavel Safronov268
0580e85e 2026-10-24 Pavel Safronov269 // A note for the end of a run (a new high score), cleared once a new run is playing.
270 if (s.status === 'playing') bannerNote = ''
271 const note = bannerNote ? ` ${bannerNote}` : ''
5551eb37 2026-10-20 Pavel Safronov272 banner.hidden = s.status === 'playing'
0580e85e 2026-10-24 Pavel Safronov273 bannerButton2.hidden = s.status !== 'gameOver'
274 bannerScores.hidden = s.status !== 'gameOver' && s.status !== 'won'
5551eb37 2026-10-20 Pavel Safronov275 if (s.status === 'dead') {
0580e85e 2026-10-24 Pavel Safronov276 bannerText.textContent = `Ouch! ${deathText(s)} ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.`
5551eb37 2026-10-20 Pavel Safronov277 bannerButton.textContent = 'Try again'
644cfd0b 2026-10-20 Pavel Safronov278 bannerIcon.src = spriteUrl('character_green_hit')
5551eb37 2026-10-20 Pavel Safronov279 } else if (s.status === 'gameOver') {
0580e85e 2026-10-24 Pavel Safronov280 bannerText.textContent = `Game over. ${deathText(s)} Seed ${s.seed}.${note}`
281 // Same dungeon again, or a new one (user request, 2026-10-24).
282 bannerButton.textContent = 'Same dungeon'
283 bannerButton2.textContent = 'New dungeon'
644cfd0b 2026-10-20 Pavel Safronov284 bannerIcon.src = spriteUrl('hud_heart_empty')
c37a2b0b 2026-10-20 Pavel Safronov285 } else if (s.status === 'cleared') {
0b63d158 2026-10-25 Pavel Safronov286 const bonus = s.turn <= s.par ? `+${CLEAR_BONUS + PAR_BONUS} coins, under par!` : `+${CLEAR_BONUS} coins.`
287 bannerText.textContent = `Level ${s.level} cleared in ${s.turn} turns. ${bonus}`
c37a2b0b 2026-10-20 Pavel Safronov288 bannerButton.textContent = 'Next level'
289 bannerIcon.src = spriteUrl('flag_green_a')
27f24fed 2026-10-20 Pavel Safronov290 } else if (s.status === 'won') {
00ad7d0a 2026-10-24 Pavel Safronov291 bannerText.textContent =
292 `You cleared all ${s.level} levels! ${s.runTurns} ${s.runTurns === 1 ? 'turn' : 'turns'}, ${s.deaths} ${s.deaths === 1 ? 'life' : 'lives'} lost. Seed ${s.seed}.` +
0580e85e 2026-10-24 Pavel Safronov293 (s.options.dev ? ' (Developer run)' : '') +
294 note
27f24fed 2026-10-20 Pavel Safronov295 bannerButton.textContent = 'New run'
296 bannerIcon.src = spriteUrl('star')
5551eb37 2026-10-20 Pavel Safronov297 }
00ad7d0a 2026-10-24 Pavel Safronov298 // A Developer run's achievements count, but carry a red border, like the red quarters some
299 // bars kept for their own plays on the jukebox (user request, 2026-10-23).
300 banner.classList.toggle('developer', s.status === 'won' && s.options.dev)
5551eb37 2026-10-20 Pavel Safronov301 },
0580e85e 2026-10-24 Pavel Safronov302 /** A line added to the game-over or victory banner (a new high score), until the next run. */
303 setBannerNote(text: string): void {
304 bannerNote = text
305 },
ebcd0a8f 2026-11-02 Pavel Safronov306 /** The kind of pad in use, for the pictures on the buttons (padMap.ts `padFamily`). */
307 setPadFamily(next: PadFamily): void {
308 family = next
92cc4ee3 2026-11-02 Pavel Safronov309 for (const [def, b] of buttons) {
310 const img = b.querySelector<HTMLImageElement>('.pad-glyph')
311 img?.setAttribute('src', spriteUrl(glyphFor(family, glyphButton(def))))
312 img?.setAttribute('data-face', String(faceOf(family, glyphButton(def))))
313 }
ebcd0a8f 2026-11-02 Pavel Safronov314 },
315 /** The controller's cursor tile: what each bar button does there, as 'ok', 'off' (not possible) or 'danger' (costs a life); null when no cursor shows. */
316 setPadCursor(states: Partial<Record<ActionId, 'ok' | 'off' | 'danger'>> | null): void {
317 for (const [def, b] of buttons) {
318 const v = states?.[def.id]
319 if (v) b.dataset.pad = v
320 else delete b.dataset.pad
321 }
322 },
323 /** A button the cursor's tile cannot use was pressed: it flashes. */
324 flashPad(id: ActionId): void {
325 const b = bar.querySelector<HTMLButtonElement>(`button[data-action="${id}"]`)
326 if (!b) return
327 b.classList.remove('pad-flash')
328 void b.offsetWidth
329 b.classList.add('pad-flash')
330 setTimeout(() => b.classList.remove('pad-flash'), 350)
331 },
5551eb37 2026-10-20 Pavel Safronov332 /** True if the banner is showing; Enter presses its button. */
333 pressBanner(): boolean {
334 if (banner.hidden) return false
335 bannerButton.click()
336 return true
337 },
338 insets() {
339 return {
0bfdc8e6 2026-10-25 Pavel Safronov340 // The bar's bottom edge: it sits lower in the demo, under the title.
341 top: document.getElementById('hud')!.getBoundingClientRect().bottom,
5551eb37 2026-10-20 Pavel Safronov342 bottom: bar.getBoundingClientRect().height,
343 }
344 },
345 }
346}