explodorama / files / src / ui / controls.ts
controls.ts
99869630 2026-10-23 Pavel Safronov | 1 | import { canPick } from '../sim/picks' |
720cc9d3 2026-11-03 Pavel Safronov | 2 | import { CLEAR_BONUS, PAR_BONUS, START_LIVES, turnNumber } from '../sim/state' |
52d68b59 2026-10-24 Pavel Safronov | 3 | import { CHARGE_IDS, type ActionId, type GameState, type PowerUpKind, type Powers } from '../sim/types' |
99869630 2026-10-23 Pavel Safronov | 4 | import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites' |
0580e85e 2026-10-24 Pavel Safronov | 5 | import { CHARGE_TEXT, chargeLeft, deathText, type HudItem } from './info' |
ebcd0a8f 2026-11-02 Pavel Safronov | 6 | import { glyphFor, type GlyphButton } from './padGlyph' |
92cc4ee3 2026-11-02 Pavel Safronov | 7 | import { faceOf, type PadFamily } from './padMap' |
0580e85e 2026-10-24 Pavel Safronov | 8 | import { openScores } from './scores' |
ea380ee8 2026-10-22 Pavel Safronov | 9 | import questionIcon from '../../assets/kenney-icons/question.png' |
720cc9d3 2026-11-03 Pavel Safronov | 10 | import cancelIcon from '../../assets/kenney-icons/cancel.png' |
5551eb37 2026-10-20 Pavel Safronov | 11 | |
c37a2b0b 2026-10-20 Pavel Safronov | 12 | /** The HTML around the board: HUD, action bar, and the banner shown between attempts and levels. */ |
5551eb37 2026-10-20 Pavel Safronov | 13 | export interface Handlers { |
99869630 2026-10-23 Pavel Safronov | 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 | |
0580e85e 2026-10-24 Pavel Safronov | 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 | |
a4fab0fb 2026-10-21 Pavel Safronov | 24 | /** A top-bar item was tapped: show what it means. */ |
| 25 | info(item: HudItem): void | |
99869630 2026-10-23 Pavel Safronov | 26 | /** The "What is that?" button: the next tap on the board (or an action button) explains it. */ |
ea380ee8 2026-10-22 Pavel Safronov | 27 | inspect(): void |
720cc9d3 2026-11-03 Pavel Safronov | 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 | |
a4fab0fb 2026-10-21 Pavel Safronov | 30 | menu(): void |
5551eb37 2026-10-20 Pavel Safronov | 31 | } |
| 32 | ||
99869630 2026-10-23 Pavel Safronov | 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 | |
d3e4ed26 2026-10-25 Pavel Safronov | 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. | |
99869630 2026-10-23 Pavel Safronov | 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[] | |
c37a2b0b 2026-10-20 Pavel Safronov | 46 | } |
644cfd0b 2026-10-20 Pavel Safronov | 47 | |
99869630 2026-10-23 Pavel Safronov | 48 | const BUTTONS: ButtonDef[] = [ |
fcf44f32 2026-10-25 Pavel Safronov | 49 | { id: 'move', label: 'Move', icon: 'icon_move', key: 'M', group: ['move', 'run', 'kick', 'diagonal', 'slide'] }, |
d3e4ed26 2026-10-25 Pavel Safronov | 50 | { id: 'bomb', label: 'Bomb', icon: 'bomb', key: 'B', group: ['bomb', 'throw', 'rpg', 'detonate'] }, |
5f066960 2026-10-26 Pavel Safronov | 51 | { id: 'charged', label: 'Charge', icon: 'bomb', key: 'V', group: ['charged', 'icbm', 'freeze', 'decoy', 'teleport'] }, |
99869630 2026-10-23 Pavel Safronov | 52 | { id: 'wait', label: 'Wait', icon: 'icon_wait', key: 'Space', group: ['wait'] }, |
| 53 | ] | |
| 54 | ||
27f24fed 2026-10-20 Pavel Safronov | 55 | /** The passes shown in the HUD, with their power-up and name. */ |
| 56 | const PASSES: [keyof Powers, PowerUpKind, string][] = [ | |
99869630 2026-10-23 Pavel Safronov | 57 | ['run', 'run', 'Run'], |
| 58 | ['kick', 'kick', 'Kick'], | |
| 59 | ['throw', 'throw', 'Throw'], | |
52d68b59 2026-10-24 Pavel Safronov | 60 | ['diagonal', 'diagonal', 'Diagonal'], |
27f24fed 2026-10-20 Pavel Safronov | 61 | ['detonate', 'detonator', 'Detonator'], |
ebcd0a8f 2026-11-02 Pavel Safronov | 62 | ['wallPass', 'wallPass', 'Crate Pass'], |
27f24fed 2026-10-20 Pavel Safronov | 63 | ['bombPass', 'bombPass', 'Bomb Pass'], |
7f242d36 2026-10-25 Pavel Safronov | 64 | ['rpg', 'rpg', 'RPG'], |
| 65 | ['proximity', 'proximity', 'Proximity alert'], | |
| 66 | ['slide', 'slide', 'Slide'], | |
27f24fed 2026-10-20 Pavel Safronov | 67 | ] |
| 68 | ||
a4fab0fb 2026-10-21 Pavel Safronov | 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 | ||
ea380ee8 2026-10-22 Pavel Safronov | 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 | |
52d68b59 2026-10-24 Pavel Safronov | 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 | } | |
ea380ee8 2026-10-22 Pavel Safronov | 97 | return c |
| 98 | }), | |
| 99 | ) | |
| 100 | } | |
| 101 | ||
644cfd0b 2026-10-20 Pavel Safronov | 102 | function icon(name: string, className = 'icon'): string { |
99869630 2026-10-23 Pavel Safronov | 103 | const mono = MONO_ICONS.has(name) ? ' mono' : '' |
| 104 | return `<img class="${className}${mono}" src="${spriteUrl(name)}" alt="" draggable="false">` | |
644cfd0b 2026-10-20 Pavel Safronov | 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 | } | |
5551eb37 2026-10-20 Pavel Safronov | 111 | |
| 112 | export function createControls(handlers: Handlers) { | |
| 113 | const bar = document.getElementById('actions')! | |
ebcd0a8f 2026-11-02 Pavel Safronov | 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)) | |
92cc4ee3 2026-11-02 Pavel Safronov | 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">` | |
99869630 2026-10-23 Pavel Safronov | 119 | const buttons = new Map<ButtonDef, HTMLButtonElement>() |
| 120 | for (const def of BUTTONS) { | |
5551eb37 2026-10-20 Pavel Safronov | 121 | const b = document.createElement('button') |
| 122 | b.type = 'button' | |
99869630 2026-10-23 Pavel Safronov | 123 | b.dataset.action = def.id |
| 124 | b.title = def.label | |
| 125 | b.setAttribute('aria-label', def.label) | |
ebcd0a8f 2026-11-02 Pavel Safronov | 126 | b.innerHTML = `${icon(def.icon)}<kbd>${def.key}</kbd>${glyph(def)}` |
99869630 2026-10-23 Pavel Safronov | 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 | }) | |
5551eb37 2026-10-20 Pavel Safronov | 131 | bar.append(b) |
99869630 2026-10-23 Pavel Safronov | 132 | buttons.set(def, b) |
| 133 | } | |
| 134 | ||
720cc9d3 2026-11-03 Pavel Safronov | 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 | ||
99869630 2026-10-23 Pavel Safronov | 147 | let current: GameState | null = null |
0580e85e 2026-10-24 Pavel Safronov | 148 | let bannerNote = '' |
99869630 2026-10-23 Pavel Safronov | 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">` | |
ea380ee8 2026-10-22 Pavel Safronov | 154 | inspectButton.addEventListener('click', () => handlers.inspect()) |
| 155 | ||
5551eb37 2026-10-20 Pavel Safronov | 156 | const banner = document.getElementById('banner')! |
| 157 | const bannerText = document.getElementById('banner-text')! | |
644cfd0b 2026-10-20 Pavel Safronov | 158 | const bannerIcon = document.getElementById('banner-icon') as HTMLImageElement |
5551eb37 2026-10-20 Pavel Safronov | 159 | const bannerButton = document.getElementById('banner-button') as HTMLButtonElement |
0580e85e 2026-10-24 Pavel Safronov | 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()) | |
5551eb37 2026-10-20 Pavel Safronov | 166 | |
a4fab0fb 2026-10-21 Pavel Safronov | 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 | ||
5551eb37 2026-10-20 Pavel Safronov | 174 | 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 Safronov | 178 | enemies: document.getElementById('hud-enemies')!, |
| 179 | coins: document.getElementById('hud-coins')!, | |
5551eb37 2026-10-20 Pavel Safronov | 180 | stats: document.getElementById('hud-stats')!, |
52d68b59 2026-10-24 Pavel Safronov | 181 | extras: document.getElementById('hud-extras')!, |
beb9f2b7 2026-10-21 Pavel Safronov | 182 | modes: document.getElementById('hud-modes')!, |
5551eb37 2026-10-20 Pavel Safronov | 183 | } |
| 184 | ||
| 185 | return { | |
e3d8dc49 2026-10-27 Pavel Safronov | 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 { | |
ea380ee8 2026-10-22 Pavel Safronov | 191 | inspectButton.setAttribute('aria-pressed', String(inspecting)) |
720cc9d3 2026-11-03 Pavel Safronov | 192 | cancelButton.hidden = !(s.status === 'playing' && !inspecting && (selected !== null || autoMove)) |
ea380ee8 2026-10-22 Pavel Safronov | 193 | inspectButton.disabled = s.status !== 'playing' |
99869630 2026-10-23 Pavel Safronov | 194 | current = s |
| 195 | currentSelected = selected | |
| 196 | for (const [def, b] of buttons) { | |
d3e4ed26 2026-10-25 Pavel Safronov | 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)) | |
99869630 2026-10-23 Pavel Safronov | 199 | b.setAttribute('aria-pressed', String(!!selected && def.group.includes(selected))) |
d3e4ed26 2026-10-25 Pavel Safronov | 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 | |
ebcd0a8f 2026-11-02 Pavel Safronov | 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)}` | |
d3e4ed26 2026-10-25 Pavel Safronov | 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 | } | |
e3d8dc49 2026-10-27 Pavel Safronov | 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 | } | |
99869630 2026-10-23 Pavel Safronov | 223 | if (def.id === 'move') { |
52d68b59 2026-10-24 Pavel Safronov | 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. | |
99869630 2026-10-23 Pavel Safronov | 227 | b.setAttribute('aria-label', autoMove ? 'Go' : def.label) |
| 228 | b.title = autoMove ? 'Go' : def.label | |
82adee18 2026-10-20 Pavel Safronov | 229 | b.classList.toggle('auto', autoMove) |
| 230 | } | |
5551eb37 2026-10-20 Pavel Safronov | 231 | } |
644cfd0b 2026-10-20 Pavel Safronov | 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 | ) | |
00ad7d0a 2026-10-24 Pavel Safronov | 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) | |
ea380ee8 2026-10-22 Pavel Safronov | 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 '))) | |
720cc9d3 2026-11-03 Pavel Safronov | 243 | // The turn the player is on (from 1), and the par to beat for the bonus. |
ea380ee8 2026-10-22 Pavel Safronov | 244 | show( |
| 245 | hud.turn, | |
720cc9d3 2026-11-03 Pavel Safronov | 246 | item('turn', `<small aria-hidden="true">Turn</small>` + number(turnNumber(s), 'Turn ') + `<small class="par">/${s.par}</small>`), |
a4fab0fb 2026-10-21 Pavel Safronov | 247 | ) |
27f24fed 2026-10-20 Pavel Safronov | 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]) | |
a4fab0fb 2026-10-21 Pavel Safronov | 250 | .map(([p, kind, name]) => item(p, `<span class="sr-only">${name}</span>${icon(POWER_UP_ICONS[kind], 'icon small')}`)) |
27f24fed 2026-10-20 Pavel Safronov | 251 | .join('') |
a4fab0fb 2026-10-21 Pavel Safronov | 252 | const star = s.star > 0 ? item('star', icon('star', 'icon small') + number(s.star, 'Star for ')) : '' |
d3e4ed26 2026-10-25 Pavel Safronov | 253 | // The charge held, with what is left, and a Freeze or Decoy at work, with its turns. |
52d68b59 2026-10-24 Pavel Safronov | 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('') | |
ea380ee8 2026-10-22 Pavel Safronov | 260 | show( |
| 261 | hud.stats, | |
a4fab0fb 2026-10-21 Pavel Safronov | 262 | item('bombs', icon('bomb') + number(s.stats.capacity, 'Bombs ')) + |
ea380ee8 2026-10-22 Pavel Safronov | 263 | item('fire', icon('fireball') + number(s.stats.fire, 'Blast ')) + |
52d68b59 2026-10-24 Pavel Safronov | 264 | item('fuse', icon('icon_fuse') + number(s.stats.fuse, 'Fuse ')), |
ea380ee8 2026-10-22 Pavel Safronov | 265 | ) |
52d68b59 2026-10-24 Pavel Safronov | 266 | // 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 Safronov | 268 | |
0580e85e 2026-10-24 Pavel Safronov | 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}` : '' | |
5551eb37 2026-10-20 Pavel Safronov | 272 | banner.hidden = s.status === 'playing' |
0580e85e 2026-10-24 Pavel Safronov | 273 | bannerButton2.hidden = s.status !== 'gameOver' |
| 274 | bannerScores.hidden = s.status !== 'gameOver' && s.status !== 'won' | |
5551eb37 2026-10-20 Pavel Safronov | 275 | if (s.status === 'dead') { |
0580e85e 2026-10-24 Pavel Safronov | 276 | bannerText.textContent = `Ouch! ${deathText(s)} ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.` |
5551eb37 2026-10-20 Pavel Safronov | 277 | bannerButton.textContent = 'Try again' |
644cfd0b 2026-10-20 Pavel Safronov | 278 | bannerIcon.src = spriteUrl('character_green_hit') |
5551eb37 2026-10-20 Pavel Safronov | 279 | } else if (s.status === 'gameOver') { |
0580e85e 2026-10-24 Pavel Safronov | 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' | |
644cfd0b 2026-10-20 Pavel Safronov | 284 | bannerIcon.src = spriteUrl('hud_heart_empty') |
c37a2b0b 2026-10-20 Pavel Safronov | 285 | } else if (s.status === 'cleared') { |
0b63d158 2026-10-25 Pavel Safronov | 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}` | |
c37a2b0b 2026-10-20 Pavel Safronov | 288 | bannerButton.textContent = 'Next level' |
| 289 | bannerIcon.src = spriteUrl('flag_green_a') | |
27f24fed 2026-10-20 Pavel Safronov | 290 | } else if (s.status === 'won') { |
00ad7d0a 2026-10-24 Pavel Safronov | 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}.` + | |
0580e85e 2026-10-24 Pavel Safronov | 293 | (s.options.dev ? ' (Developer run)' : '') + |
| 294 | note | |
27f24fed 2026-10-20 Pavel Safronov | 295 | bannerButton.textContent = 'New run' |
| 296 | bannerIcon.src = spriteUrl('star') | |
5551eb37 2026-10-20 Pavel Safronov | 297 | } |
00ad7d0a 2026-10-24 Pavel Safronov | 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) | |
5551eb37 2026-10-20 Pavel Safronov | 301 | }, |
0580e85e 2026-10-24 Pavel Safronov | 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 | }, | |
ebcd0a8f 2026-11-02 Pavel Safronov | 306 | /** 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 Safronov | 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 | } | |
ebcd0a8f 2026-11-02 Pavel Safronov | 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 | }, | |
5551eb37 2026-10-20 Pavel Safronov | 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 { | |
0bfdc8e6 2026-10-25 Pavel Safronov | 340 | // 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 Safronov | 342 | bottom: bar.getBoundingClientRect().height, |
| 343 | } | |
| 344 | }, | |
| 345 | } | |
| 346 | } |