explodorama / files / src / ui / controls.ts
controls.ts
| 1 | import { canPick } from '../sim/picks' |
| 2 | import { CLEAR_BONUS, PAR_BONUS, START_LIVES, turnNumber } from '../sim/state' |
| 3 | import { CHARGE_IDS, type ActionId, type GameState, type PowerUpKind, type Powers } from '../sim/types' |
| 4 | import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites' |
| 5 | import { CHARGE_TEXT, chargeLeft, deathText, type HudItem } from './info' |
| 6 | import { glyphFor, type GlyphButton } from './padGlyph' |
| 7 | import { faceOf, type PadFamily } from './padMap' |
| 8 | import { openScores } from './scores' |
| 9 | import questionIcon from '../../assets/kenney-icons/question.png' |
| 10 | import cancelIcon from '../../assets/kenney-icons/cancel.png' |
| 11 | |
| 12 | /** The HTML around the board: HUD, action bar, and the banner shown between attempts and levels. */ |
| 13 | export interface Handlers { |
| 14 | /** |
| 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 |
| 19 | /** |
| 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 |
| 24 | /** A top-bar item was tapped: show what it means. */ |
| 25 | info(item: HudItem): void |
| 26 | /** The "What is that?" button: the next tap on the board (or an action button) explains it. */ |
| 27 | inspect(): void |
| 28 | /** The Cancel button (shown while an action is picked or auto-move has a destination): puts it away, as Esc does. */ |
| 29 | cancel(): void |
| 30 | menu(): void |
| 31 | } |
| 32 | |
| 33 | /** |
| 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 |
| 36 | * 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. |
| 38 | */ |
| 39 | interface 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[] |
| 46 | } |
| 47 | |
| 48 | const BUTTONS: ButtonDef[] = [ |
| 49 | { id: 'move', label: 'Move', icon: 'icon_move', key: 'M', group: ['move', 'run', 'kick', 'diagonal', 'slide'] }, |
| 50 | { id: 'bomb', label: 'Bomb', icon: 'bomb', key: 'B', group: ['bomb', 'throw', 'rpg', 'detonate'] }, |
| 51 | { id: 'charged', label: 'Charge', icon: 'bomb', key: 'V', group: ['charged', 'icbm', 'freeze', 'decoy', 'teleport'] }, |
| 52 | { id: 'wait', label: 'Wait', icon: 'icon_wait', key: 'Space', group: ['wait'] }, |
| 53 | ] |
| 54 | |
| 55 | /** The passes shown in the HUD, with their power-up and name. */ |
| 56 | const PASSES: [keyof Powers, PowerUpKind, string][] = [ |
| 57 | ['run', 'run', 'Run'], |
| 58 | ['kick', 'kick', 'Kick'], |
| 59 | ['throw', 'throw', 'Throw'], |
| 60 | ['diagonal', 'diagonal', 'Diagonal'], |
| 61 | ['detonate', 'detonator', 'Detonator'], |
| 62 | ['wallPass', 'wallPass', 'Crate Pass'], |
| 63 | ['bombPass', 'bombPass', 'Bomb Pass'], |
| 64 | ['rpg', 'rpg', 'RPG'], |
| 65 | ['proximity', 'proximity', 'Proximity alert'], |
| 66 | ['slide', 'slide', 'Slide'], |
| 67 | ] |
| 68 | |
| 69 | /** A top-bar item: a button that opens its info (`data-info`, see ui/info.ts). */ |
| 70 | function item(info: HudItem, html: string): string { |
| 71 | return `<button type="button" class="hud-item" data-info="${info}">${html}</button>` |
| 72 | } |
| 73 | |
| 74 | /** |
| 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 | */ |
| 79 | function 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 |
| 91 | 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 | } |
| 97 | return c |
| 98 | }), |
| 99 | ) |
| 100 | } |
| 101 | |
| 102 | function icon(name: string, className = 'icon'): string { |
| 103 | const mono = MONO_ICONS.has(name) ? ' mono' : '' |
| 104 | return `<img class="${className}${mono}" src="${spriteUrl(name)}" alt="" draggable="false">` |
| 105 | } |
| 106 | |
| 107 | /** A number in the pack's HUD digits. The images are decoration; `label` is what screen readers get. */ |
| 108 | function 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 | } |
| 111 | |
| 112 | export function createControls(handlers: Handlers) { |
| 113 | const bar = document.getElementById('actions')! |
| 114 | // 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)) |
| 117 | // 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">` |
| 119 | const buttons = new Map<ButtonDef, HTMLButtonElement>() |
| 120 | for (const def of BUTTONS) { |
| 121 | const b = document.createElement('button') |
| 122 | b.type = 'button' |
| 123 | b.dataset.action = def.id |
| 124 | b.title = def.label |
| 125 | b.setAttribute('aria-label', def.label) |
| 126 | b.innerHTML = `${icon(def.icon)}<kbd>${def.key}</kbd>${glyph(def)}` |
| 127 | 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 | }) |
| 131 | bar.append(b) |
| 132 | buttons.set(def, b) |
| 133 | } |
| 134 | |
| 135 | // 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 | |
| 147 | let current: GameState | null = null |
| 148 | let bannerNote = '' |
| 149 | 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">` |
| 154 | inspectButton.addEventListener('click', () => handlers.inspect()) |
| 155 | |
| 156 | const banner = document.getElementById('banner')! |
| 157 | const bannerText = document.getElementById('banner-text')! |
| 158 | const bannerIcon = document.getElementById('banner-icon') as HTMLImageElement |
| 159 | const bannerButton = document.getElementById('banner-button') as HTMLButtonElement |
| 160 | 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()) |
| 166 | |
| 167 | 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 | |
| 174 | const hud = { |
| 175 | lives: document.getElementById('hud-lives')!, |
| 176 | level: document.getElementById('hud-level')!, |
| 177 | turn: document.getElementById('hud-turn')!, |
| 178 | enemies: document.getElementById('hud-enemies')!, |
| 179 | coins: document.getElementById('hud-coins')!, |
| 180 | stats: document.getElementById('hud-stats')!, |
| 181 | extras: document.getElementById('hud-extras')!, |
| 182 | modes: document.getElementById('hud-modes')!, |
| 183 | } |
| 184 | |
| 185 | return { |
| 186 | /** |
| 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 { |
| 191 | inspectButton.setAttribute('aria-pressed', String(inspecting)) |
| 192 | cancelButton.hidden = !(s.status === 'playing' && !inspecting && (selected !== null || autoMove)) |
| 193 | inspectButton.disabled = s.status !== 'playing' |
| 194 | current = s |
| 195 | currentSelected = selected |
| 196 | for (const [def, b] of buttons) { |
| 197 | // 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)) |
| 199 | b.setAttribute('aria-pressed', String(!!selected && def.group.includes(selected))) |
| 200 | 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 |
| 210 | ? `${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)}` |
| 212 | b.title = kind ? name : 'Charge: none carried' |
| 213 | b.setAttribute('aria-label', kind ? `${name} charge, ${s.charge!.left} left` : 'Charge: none carried') |
| 214 | } |
| 215 | } |
| 216 | 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 | } |
| 223 | if (def.id === 'move') { |
| 224 | // 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. |
| 227 | b.setAttribute('aria-label', autoMove ? 'Go' : def.label) |
| 228 | b.title = autoMove ? 'Go' : def.label |
| 229 | b.classList.toggle('auto', autoMove) |
| 230 | } |
| 231 | } |
| 232 | // 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 | ) |
| 236 | // The run's modes, as small tags, when it has any. |
| 237 | const tags = [s.options.dev && 'Dev', s.options.immortal && 'Immortal'].filter(Boolean) |
| 238 | 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 '))) |
| 243 | // The turn the player is on (from 1), and the par to beat for the bonus. |
| 244 | show( |
| 245 | hud.turn, |
| 246 | item('turn', `<small aria-hidden="true">Turn</small>` + number(turnNumber(s), 'Turn ') + `<small class="par">/${s.par}</small>`), |
| 247 | ) |
| 248 | // 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]) |
| 250 | .map(([p, kind, name]) => item(p, `<span class="sr-only">${name}</span>${icon(POWER_UP_ICONS[kind], 'icon small')}`)) |
| 251 | .join('') |
| 252 | const star = s.star > 0 ? item('star', icon('star', 'icon small') + number(s.star, 'Star for ')) : '' |
| 253 | // The charge held, with what is left, and a Freeze or Decoy at work, with its turns. |
| 254 | 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('') |
| 260 | show( |
| 261 | hud.stats, |
| 262 | item('bombs', icon('bomb') + number(s.stats.capacity, 'Bombs ')) + |
| 263 | item('fire', icon('fireball') + number(s.stats.fire, 'Blast ')) + |
| 264 | item('fuse', icon('icon_fuse') + number(s.stats.fuse, 'Fuse ')), |
| 265 | ) |
| 266 | // Their own row, which keeps its place even while empty, so the board never moves. |
| 267 | show(hud.extras, passes + star + charges) |
| 268 | |
| 269 | // 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}` : '' |
| 272 | banner.hidden = s.status === 'playing' |
| 273 | bannerButton2.hidden = s.status !== 'gameOver' |
| 274 | bannerScores.hidden = s.status !== 'gameOver' && s.status !== 'won' |
| 275 | if (s.status === 'dead') { |
| 276 | bannerText.textContent = `Ouch! ${deathText(s)} ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.` |
| 277 | bannerButton.textContent = 'Try again' |
| 278 | bannerIcon.src = spriteUrl('character_green_hit') |
| 279 | } else if (s.status === 'gameOver') { |
| 280 | 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' |
| 284 | bannerIcon.src = spriteUrl('hud_heart_empty') |
| 285 | } else if (s.status === 'cleared') { |
| 286 | 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}` |
| 288 | bannerButton.textContent = 'Next level' |
| 289 | bannerIcon.src = spriteUrl('flag_green_a') |
| 290 | } else if (s.status === 'won') { |
| 291 | 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}.` + |
| 293 | (s.options.dev ? ' (Developer run)' : '') + |
| 294 | note |
| 295 | bannerButton.textContent = 'New run' |
| 296 | bannerIcon.src = spriteUrl('star') |
| 297 | } |
| 298 | // 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) |
| 301 | }, |
| 302 | /** 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 | }, |
| 306 | /** The kind of pad in use, for the pictures on the buttons (padMap.ts `padFamily`). */ |
| 307 | setPadFamily(next: PadFamily): void { |
| 308 | family = next |
| 309 | 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 | } |
| 314 | }, |
| 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 | }, |
| 332 | /** 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 { |
| 340 | // The bar's bottom edge: it sits lower in the demo, under the title. |
| 341 | top: document.getElementById('hud')!.getBoundingClientRect().bottom, |
| 342 | bottom: bar.getBoundingClientRect().height, |
| 343 | } |
| 344 | }, |
| 345 | } |
| 346 | } |