fuzzyworld

explodorama / files / src / ui / controls.ts

controls.ts

17.3 KB · history · blame

1import { canPick } from '../sim/picks'
2import { CLEAR_BONUS, PAR_BONUS, START_LIVES, turnNumber } from '../sim/state'
3import { CHARGE_IDS, type ActionId, type GameState, type PowerUpKind, type Powers } from '../sim/types'
4import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
5import { CHARGE_TEXT, chargeLeft, deathText, type HudItem } from './info'
6import { glyphFor, type GlyphButton } from './padGlyph'
7import { faceOf, type PadFamily } from './padMap'
8import { openScores } from './scores'
9import questionIcon from '../../assets/kenney-icons/question.png'
10import 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. */
13export 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 */
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[]
46}
47
48const 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. */
56const 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). */
70function 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 */
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
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
102function 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. */
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}
111
112export 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}