explodorama / commit
Playtest fixes: flag clears off-path, menu with New run, info dialogs, Kenney UI packs
PLAN.md | 16 ++ README.md | 26 ++- assets/kenney-ui-rpg/license.txt | 14 ++ assets/kenney-ui-rpg/panel_beigeLight.png | Bin 0 -> 785 bytes assets/kenney-ui/Extra/icon_play_light.png | Bin 0 -> 489 bytes assets/kenney-ui/Extra/icon_repeat_light.png | Bin 0 -> 733 bytes .../Green/button_rectangle_depth_flat.png | Bin 0 -> 785 bytes .../kenney-ui/Grey/button_rectangle_depth_flat.png | Bin 0 -> 786 bytes assets/kenney-ui/Grey/button_round_depth_flat.png | Bin 0 -> 2470 bytes assets/kenney-ui/Grey/icon_cross.png | Bin 0 -> 516 bytes assets/kenney-ui/Kenney Future.ttf | Bin 0 -> 34160 bytes assets/kenney-ui/License.txt | 22 +++ .../kenney-ui/Red/button_rectangle_depth_flat.png | Bin 0 -> 785 bytes decisions.md | 17 ++ docs/screenshots/auto-move.png | Bin 243724 -> 248711 bytes docs/screenshots/bomb-picked.png | Bin 243572 -> 248823 bytes docs/screenshots/charges.png | Bin 273479 -> 270548 bytes docs/screenshots/countdown.png | Bin 248462 -> 253449 bytes docs/screenshots/death.png | Bin 202732 -> 214407 bytes docs/screenshots/desktop.png | Bin 589981 -> 593323 bytes docs/screenshots/enemies.png | Bin 254864 -> 252484 bytes docs/screenshots/info-enemy.png | Bin 0 -> 192054 bytes docs/screenshots/info-hud.png | Bin 0 -> 182092 bytes docs/screenshots/lingering.png | Bin 252768 -> 257773 bytes docs/screenshots/long-press.png | Bin 246984 -> 252078 bytes docs/screenshots/menu.png | Bin 0 -> 186148 bytes docs/screenshots/new-run.png | Bin 0 -> 175001 bytes docs/screenshots/pierce-decoy.png | Bin 282258 -> 280079 bytes docs/screenshots/power-up.png | Bin 260047 -> 261789 bytes docs/screenshots/run.png | Bin 243745 -> 237155 bytes docs/screenshots/scrolled.png | Bin 257350 -> 261115 bytes docs/screenshots/start.png | Bin 242525 -> 247709 bytes index.html | 12 +- src/main.ts | 98 ++++++++-- src/style.css | 168 +++++++++++++++++ src/ui/controls.ts | 41 ++++- src/ui/dialog.ts | 65 +++++++ src/ui/info.ts | 201 +++++++++++++++++++++ tools/screenshots.mjs | 12 ++ 39 files changed, 663 insertions(+), 29 deletions(-) diff --git a/PLAN.md b/PLAN.md index 9543248..c4abd62 100644 --- a/PLAN.md +++ b/PLAN.md @@ -98,6 +98,14 @@ Starting values can be raised by Workshop tiers (§7). | **Place bomb** | On any **adjacent** free tile (orthogonal). Limited by bomb capacity. | Valid tiles. Hovering/pressing one shows the faded **blast cross** and the starting timer. | | **Wait** | Do nothing; the world advances one turn. | Nothing (or a subtle pulse on the player). | +**Info and menu (user request, 2026-10-21; built):** tapping anything that isn't an action shows +a dialog explaining it (`ui/info.ts`): enemies (their next moves in words), bombs, burning +flame, walls and crates, the exit, the power-up, drops, and every top-bar item. Enemies, bombs and +burning tiles always show info, even next to the player (so a tap never walks into an enemy). +Walls and crates clear the auto-move flag if there is one, otherwise show info. Things the player +can walk to get a **Walk there** button. A **menu** button (top right; Esc with nothing to +cancel) shows level and seed, credits Kenney, and offers **New run** behind a confirmation. + **Touch shortcuts (user request, 2026-10-20; built):** - **Tap the player** = Wait. @@ -108,6 +116,8 @@ Starting values can be raised by Workshop tiers (§7). destination (flag + dotted path, next step highlighted). The Move button turns into **Go**; each press takes one step along the shortest walk (`sim/path.ts`, over free tiles, ignoring enemies), so it is still one turn per step and fully recorded as ordinary Move actions. + **Moving anywhere off the path clears the flag** ("changed my mind", user request, 2026-10-21): + any move, run, diagonal step or teleport that doesn't land on the path. **Tapping the flag also takes one step** (user request, 2026-10-20). Tapping another far tile moves the flag. Recomputed every turn; cleared on arrival, when unreachable, on Esc, by tapping a wall or crate, or on death / level change. Neither Go nor the flag takes a known-deadly step @@ -420,6 +430,12 @@ plan of black Icongeek26 outline icons from Flaticon over a plain drawn board. sound is added (M7), copied into `assets/kenney/` like the sprites; ZzFX only for anything the pack has no sound for (e.g. the explosion). +- **UI (2026-10-21):** dialogs and their buttons use Kenney's **UI Pack 2.0** (rectangle depth + buttons, green / red / grey; round grey button for close and the menu; play / repeat icons; the + **Kenney Future** font for titles) and the **UI Pack: RPG Expansion** parchment panel, as CSS + 9-slice `border-image`s. Files in `assets/kenney-ui/` (pack colour folders kept) and + `assets/kenney-ui-rpg/`, each with its licence. Credited with the platformer pack. + Sprite assignments (✅ = drawn now; the rest are copied and waiting for their feature): | Thing | Sprite(s) in `assets/kenney/` | diff --git a/README.md b/README.md index 9a33ed1..63d9837 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,21 @@ where their arrows pointed, bombs count down and explode, and enemies show their **Move** button becomes **Go**: each press of Go — or each tap on the flag itself — takes **one step** along the way (one turn, as always — the world still moves between steps). The way is worked out again every turn. Tapping another far tile moves the flag there. Auto-move ends when -you arrive, when the flag can no longer be reached, when you tap a wall or crate, or on **Esc**. +you arrive, when you **move somewhere off the dotted way** (you changed your mind), when the flag +can no longer be reached, when you tap a wall or crate, or on **Esc**. It **won't take a deadly step**: onto an enemy, onto a tile an enemy's arrow points at, or into flame this turn. The next step then shows red, and Go / the flag wait for you to deal with it (Go falls back to picking Move, so you can step aside yourself). +**What is that?** Tap anything that isn't a move to see what it is and what it does: an +**enemy** (with its next moves spelled out), a **bomb**, burning flame, a wall or crate, the exit, +the power-up, a drop — and every item in the **top bar** (lives, level, enemies left, coins, +turn / par, bombs, blast, fuse, passes, Star). For things you can walk to, the dialog has a +**Walk there** button that sets the auto-move flag. Tapping an enemy never walks into it. + +**Menu:** the round button at the top right (or **Esc** with nothing picked). It shows the level +and seed, and has **New run**, which asks first, since the current run can't be got back. + **Press and hold** a tile next to you (about half a second, with a short buzz on phones that support it) to arm a bomb there: its blast shows, and letting go places it. Sliding off the tile first cancels it. @@ -207,14 +217,18 @@ Design and decisions: [PLAN.md](PLAN.md), [decisions.md](decisions.md). ## Attribution -Art: [New Platformer Pack](https://kenney.nl/assets/new-platformer-pack) 1.1 by +Art: [New Platformer Pack](https://kenney.nl/assets/new-platformer-pack) 1.1, +[UI Pack](https://kenney.nl/assets/ui-pack) 2.0 (with its Kenney Future font) and +[UI Pack: RPG Expansion](https://kenney.nl/assets/ui-pack-rpg-expansion), all by [Kenney](https://www.kenney.nl), under [CC0](http://creativecommons.org/publicdomain/zero/1.0/) (public domain). Kenney doesn't require credit, but the pack was chosen deliberately and the game -looks the way it does because of it, so it is credited here and in the game (on the banner shown -after each death). Thank you, Kenney! Support his work at <https://www.kenney.nl/donate>. +looks the way it does because of it, so it is credited here and in the game (in the menu, and on +the banner shown after each death). Thank you, Kenney! Support his work at <https://www.kenney.nl/donate>. -The sprites used are copied, unchanged and under their original file names, into -[`assets/kenney/`](assets/kenney/) with the pack's [licence file](assets/kenney/License.txt). They +The files used are copied, unchanged and under their original file names, into +[`assets/kenney/`](assets/kenney/), [`assets/kenney-ui/`](assets/kenney-ui/) (keeping the pack's +colour folders) and [`assets/kenney-ui-rpg/`](assets/kenney-ui-rpg/), each with its pack's +licence file. They are not covered by this project's MIT licence. Sound effects, when the game gets them, will come from the same pack. diff --git a/assets/kenney-ui-rpg/license.txt b/assets/kenney-ui-rpg/license.txt new file mode 100644 index 0000000..68c10dd --- /dev/null +++ b/assets/kenney-ui-rpg/license.txt @@ -0,0 +1,14 @@ + +############################################################################### + + UI pack: RPG extension by Kenney Vleugels (www.kenney.nl) + + ------------------------------ + + License (CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + You may use these graphics in personal and commercial projects. + Credit (Kenney or www.kenney.nl) would be nice but is not mandatory. + +############################################################################### \ No newline at end of file diff --git a/assets/kenney-ui-rpg/panel_beigeLight.png b/assets/kenney-ui-rpg/panel_beigeLight.png new file mode 100644 index 0000000..8ccf82c Binary files /dev/null and b/assets/kenney-ui-rpg/panel_beigeLight.png differ diff --git a/assets/kenney-ui/Extra/icon_play_light.png b/assets/kenney-ui/Extra/icon_play_light.png new file mode 100644 index 0000000..8236295 Binary files /dev/null and b/assets/kenney-ui/Extra/icon_play_light.png differ diff --git a/assets/kenney-ui/Extra/icon_repeat_light.png b/assets/kenney-ui/Extra/icon_repeat_light.png new file mode 100644 index 0000000..f5e0387 Binary files /dev/null and b/assets/kenney-ui/Extra/icon_repeat_light.png differ diff --git a/assets/kenney-ui/Green/button_rectangle_depth_flat.png b/assets/kenney-ui/Green/button_rectangle_depth_flat.png new file mode 100644 index 0000000..cb06457 Binary files /dev/null and b/assets/kenney-ui/Green/button_rectangle_depth_flat.png differ diff --git a/assets/kenney-ui/Grey/button_rectangle_depth_flat.png b/assets/kenney-ui/Grey/button_rectangle_depth_flat.png new file mode 100644 index 0000000..7ac4ba7 Binary files /dev/null and b/assets/kenney-ui/Grey/button_rectangle_depth_flat.png differ diff --git a/assets/kenney-ui/Grey/button_round_depth_flat.png b/assets/kenney-ui/Grey/button_round_depth_flat.png new file mode 100644 index 0000000..8ae447e Binary files /dev/null and b/assets/kenney-ui/Grey/button_round_depth_flat.png differ diff --git a/assets/kenney-ui/Grey/icon_cross.png b/assets/kenney-ui/Grey/icon_cross.png new file mode 100644 index 0000000..eb80975 Binary files /dev/null and b/assets/kenney-ui/Grey/icon_cross.png differ diff --git a/assets/kenney-ui/Kenney Future.ttf b/assets/kenney-ui/Kenney Future.ttf new file mode 100644 index 0000000..17e2b6c Binary files /dev/null and b/assets/kenney-ui/Kenney Future.ttf differ diff --git a/assets/kenney-ui/License.txt b/assets/kenney-ui/License.txt new file mode 100644 index 0000000..a8334da --- /dev/null +++ b/assets/kenney-ui/License.txt @@ -0,0 +1,22 @@ + + + UI Pack (2.0) + + Created/distributed by Kenney (www.kenney.nl) + Creation date: 12-06-2024 + + ------------------------------ + + License: (Creative Commons Zero, CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + This content is free to use in personal, educational and commercial projects. + Support us by crediting Kenney or www.kenney.nl (this is not mandatory) + + ------------------------------ + + Donate: http://support.kenney.nl + Patreon: http://patreon.com/kenney/ + + Follow on Twitter for updates: + http://twitter.com/KenneyNL \ No newline at end of file diff --git a/assets/kenney-ui/Red/button_rectangle_depth_flat.png b/assets/kenney-ui/Red/button_rectangle_depth_flat.png new file mode 100644 index 0000000..01883b0 Binary files /dev/null and b/assets/kenney-ui/Red/button_rectangle_depth_flat.png differ diff --git a/decisions.md b/decisions.md index bc4ef2d..f8b18e3 100644 --- a/decisions.md +++ b/decisions.md @@ -176,3 +176,20 @@ fix, two parts: the seed. The victory certificate is M8. - `SIM_VERSION` is now 4: older saved runs are not resumed (they would replay differently). +### 2026-10-21 — Playtest feedback: flag, new run, info dialogs, Kenney UI packs + +- **The auto-move flag clears when the player moves off its path.** Any player move (move, run, + diagonal, teleport) that doesn't land on a tile of the current path means "changed my mind". + Bombs, waits and charges don't clear it. +- **New run lives in a menu, behind a confirmation**, since a run can't be got back. The menu + button is in the top bar; Esc opens it when there is nothing to cancel. +- **Tapping anything that isn't an action explains it.** One native `<dialog>` (focus trap, Esc + to close) for info, the menu and confirmations. Enemies, bombs and burning tiles always explain + rather than act, even next to the player: a tap on an enemy used to walk into it, which was + never what anyone meant. Walls and crates keep clearing the flag when there is one (that tap + did nothing else), otherwise explain. Things you can walk to offer "Walk there". +- **UI art from Kenney's UI Pack 2.0 and UI Pack: RPG Expansion** (the user added both, CC0), + as CSS 9-slice `border-image`s so the panels and buttons stretch to any text. Credited in the + README and the menu like the platformer pack. The action bar keeps its CSS-drawn buttons for + now; they already follow the same look. + diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png index a61e0b0..4b18454 100644 Binary files a/docs/screenshots/auto-move.png and b/docs/screenshots/auto-move.png differ diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png index 76cc335..8826509 100644 Binary files a/docs/screenshots/bomb-picked.png and b/docs/screenshots/bomb-picked.png differ diff --git a/docs/screenshots/charges.png b/docs/screenshots/charges.png index ba0bdc6..7a0c39f 100644 Binary files a/docs/screenshots/charges.png and b/docs/screenshots/charges.png differ diff --git a/docs/screenshots/countdown.png b/docs/screenshots/countdown.png index 0077429..72d3c92 100644 Binary files a/docs/screenshots/countdown.png and b/docs/screenshots/countdown.png differ diff --git a/docs/screenshots/death.png b/docs/screenshots/death.png index 44cc235..cffcc5d 100644 Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png index 3a81306..e85a36b 100644 Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png index a2587b4..3ebf454 100644 Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ diff --git a/docs/screenshots/info-enemy.png b/docs/screenshots/info-enemy.png new file mode 100644 index 0000000..7c758dd Binary files /dev/null and b/docs/screenshots/info-enemy.png differ diff --git a/docs/screenshots/info-hud.png b/docs/screenshots/info-hud.png new file mode 100644 index 0000000..03168b3 Binary files /dev/null and b/docs/screenshots/info-hud.png differ diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png index 4a9396d..6314908 100644 Binary files a/docs/screenshots/lingering.png and b/docs/screenshots/lingering.png differ diff --git a/docs/screenshots/long-press.png b/docs/screenshots/long-press.png index 3d9c8f0..963e501 100644 Binary files a/docs/screenshots/long-press.png and b/docs/screenshots/long-press.png differ diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png new file mode 100644 index 0000000..a8706d0 Binary files /dev/null and b/docs/screenshots/menu.png differ diff --git a/docs/screenshots/new-run.png b/docs/screenshots/new-run.png new file mode 100644 index 0000000..9545bbe Binary files /dev/null and b/docs/screenshots/new-run.png differ diff --git a/docs/screenshots/pierce-decoy.png b/docs/screenshots/pierce-decoy.png index 8b03aaf..ccf311b 100644 Binary files a/docs/screenshots/pierce-decoy.png and b/docs/screenshots/pierce-decoy.png differ diff --git a/docs/screenshots/power-up.png b/docs/screenshots/power-up.png index c3c4fcf..c5d51fe 100644 Binary files a/docs/screenshots/power-up.png and b/docs/screenshots/power-up.png differ diff --git a/docs/screenshots/run.png b/docs/screenshots/run.png index 51a6d45..81c9777 100644 Binary files a/docs/screenshots/run.png and b/docs/screenshots/run.png differ diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png index 01a738b..ae2178a 100644 Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png index bbab4ec..4e47755 100644 Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ diff --git a/index.html b/index.html index 0ed27fa..c2d1a92 100644 --- a/index.html +++ b/index.html @@ -19,14 +19,24 @@ <span id="hud-coins"></span> <span id="hud-turn"></span> <span id="hud-stats"></span> + <button id="menu-button" type="button" aria-label="Menu"><span></span><span></span><span></span></button> </header> <nav id="actions" aria-label="Actions"></nav> <div id="banner" hidden> <img id="banner-icon" alt="" /> <p id="banner-text"></p> <button id="banner-button" type="button"></button> - <p class="credit">Art by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a> · New Platformer Pack (CC0)</p> + <p class="credit">Art by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a> · New Platformer Pack, UI Pack, UI Pack: RPG Expansion (CC0)</p> </div> +<dialog id="dialog" aria-labelledby="dialog-title"> + <div class="panel" tabindex="-1"> + <button id="dialog-close" type="button" aria-label="Close"></button> + <img id="dialog-icon" alt="" /> + <h2 id="dialog-title"></h2> + <div id="dialog-body"></div> + <div id="dialog-buttons"></div> + </div> +</dialog> <script type="module" src="/src/main.ts"></script> </body> </html> diff --git a/src/main.ts b/src/main.ts index 9f80ba6..66fb217 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,10 +18,15 @@ import { enemyAt } from './sim/enemies' import { playerPath } from './sim/path' import { predictDanger, type Danger } from './sim/danger' import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record' -import { eq, idx, inBounds, newRun } from './sim/state' +import { bombAt, cellAt, eq, idx, inBounds, isBurning, newRun } from './sim/state' import { step } from './sim/turn' import { DIRS, type Action, type ActionId, type Dir, type GameState, type Vec } from './sim/types' import { createControls } from './ui/controls' +import { dialogOpen, openDialog, type DialogButton } from './ui/dialog' +import { hudInfo, tileInfo, type Info } from './ui/info' +import { spriteUrl } from './render/sprites' +import playIcon from '../assets/kenney-ui/Extra/icon_play_light.png' +import newRunIcon from '../assets/kenney-ui/Extra/icon_repeat_light.png' // `?seed=123&level=4` gives a reproducible board (screenshots, bug reports). Otherwise a random // seed — chosen here, never inside the sim. @@ -48,6 +53,10 @@ let pressTile: Vec | null = null const LONG_PRESS_MS = 450 function startRun(seed: number): void { + selected = null + destination = null + flames = [] + snapCamera = true record = newRecord(seed, startLevel, startCharges) state = newRun(seed, startLevel, startCharges) save() @@ -93,7 +102,11 @@ function randomSeed(): number { return Math.floor(Math.random() * 2 ** 31) } +/** The actions that move the player. One that doesn't land on the auto-move path means "changed my mind". */ +const MOVES: ReadonlySet<Action['id']> = new Set(['move', 'run', 'diagonal', 'teleport']) + function act(action: Action): void { + if (MOVES.has(action.id) && 'target' in action && !path?.some((t) => eq(t, action.target!))) destination = null const result = step(state, action) state = result.state record.actions.push(action) @@ -119,10 +132,14 @@ function afterChange(): void { } /** - * Two taps: pick an action, then a highlighted tile. With nothing picked: a tap next to the - * player moves there, a tap on the player waits, a tap on a reachable tile further away sets it - * as the auto-move destination, a tap on that flag takes one step toward it (like Go), and a tap - * on a wall or crate clears it. + * Two taps: pick an action, then a highlighted tile. With nothing picked: + * - the player: Wait; + * - an enemy, a bomb, or flame still burning: what it is (info), never a move into it; + * - the auto-move flag: one step toward it (like Go), with auto-move's safety check; + * - a tile next to the player: move there; + * - a wall or crate: clears the flag if there is one, otherwise what it is; + * - the exit, the power-up, a drop or the decoy further away: what it is, with "Walk there"; + * - any other reachable tile further away: set it as the auto-move destination. */ function tapTile(p: Vec): void { if (state.status !== 'playing') return @@ -133,19 +150,71 @@ function tapTile(p: Vec): void { afterChange() } } else if (eq(p, state.player)) pick('wait') - // The flag first: even when it is next to the player, tapping it is an auto-move step, with - // auto-move's safety check, not a plain move. + else if (enemyAt(state, p) || bombAt(state, p) || isBurning(state, p)) showTileInfo(p) else if (destination && eq(destination, p)) stepTowardDestination() else if (isValidTarget(state, 'move', p)) act({ id: 'move', target: p }) - else if (playerPath(state, p)) { - destination = p - afterChange() - } else if (destination) { + else if (cellAt(state, p) !== 'floor') { + if (destination) { + destination = null + afterChange() + } else showTileInfo(p) + } else if (tileInfo(state, p)) showTileInfo(p) + else if (playerPath(state, p)) setDestination(p) + else if (destination) { destination = null afterChange() } } +function setDestination(p: Vec): void { + destination = p + afterChange() +} + +/** The info dialog for a tile; with "Walk there" when it is something to walk to. */ +function showTileInfo(p: Vec): void { + const info = tileInfo(state, p) + if (!info) return + const reachable = cellAt(state, p) === 'floor' && !isBurning(state, p) && !enemyAt(state, p) && playerPath(state, p) + showInfo(info, reachable ? [{ label: 'Walk there', kind: 'primary', onClick: () => setDestination(p) }] : []) +} + +function showInfo(info: Info, extra: DialogButton[] = []): void { + openDialog({ + icon: spriteUrl(info.icon), + title: info.title, + body: info.body.map((t) => `<p>${t}</p>`).join(''), + buttons: [...extra, { label: 'Got it', kind: 'plain' }], + }) +} + +/** The menu: resume, or abandon this run for a new one (confirmed first, since it can't be undone). */ +function showMenu(): void { + const where = state.status === 'playing' ? `Level ${state.level}, turn ${state.turn}. ` : '' + openDialog({ + title: 'Menu', + body: + `<p>${where}Seed ${state.seed}. The game saves after every move, so you can close it and come back.</p>` + + `<p class="credit">Art by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a>: ` + + `New Platformer Pack, UI Pack and UI Pack: RPG Expansion (CC0). Thank you!</p>`, + buttons: [ + { label: 'Resume', kind: 'primary', icon: playIcon }, + { label: 'New run', kind: 'danger', icon: newRunIcon, onClick: confirmNewRun }, + ], + }) +} + +function confirmNewRun(): void { + openDialog({ + title: 'Start a new run?', + body: `<p>This run — level ${state.level}, ${state.coins} coins, ${state.lives} ${state.lives === 1 ? 'life' : 'lives'} — will be lost. This can’t be undone.</p>`, + buttons: [ + { label: 'Start a new run', kind: 'danger', icon: newRunIcon, onClick: () => startRun(randomSeed()) }, + { label: 'Keep playing', kind: 'plain' }, + ], + }) +} + /** * Whether auto-move may take this step: not onto an enemy, not onto the tile an enemy moves to * next, and not into flame this turn. Those would be deaths the player didn't choose. @@ -190,6 +259,8 @@ const controls = createControls({ if (state.status === 'dead' || state.status === 'cleared') act({ id: 'continue' }) else if (state.status === 'gameOver' || state.status === 'won') startRun(randomSeed()) }, + info: (item) => showInfo(hudInfo(item, state)), + menu: showMenu, }) const KEY_DIRS: Record<string, Dir> = { @@ -208,7 +279,8 @@ const ACTION_KEYS: Record<string, ActionId> = { const DIAGONAL_KEYS: Record<string, Vec> = { KeyQ: { x: -1, y: -1 }, KeyE: { x: 1, y: -1 }, KeyZ: { x: -1, y: 1 }, KeyC: { x: 1, y: 1 } } window.addEventListener('keydown', (e) => { - if (e.repeat) return + // The dialog has the keyboard while it is open (Esc closes it, Tab moves between its buttons). + if (e.repeat || dialogOpen()) return const d = KEY_DIRS[e.code] const diag = selected === 'diagonal' ? DIAGONAL_KEYS[e.code] : undefined if (diag) { @@ -218,6 +290,8 @@ window.addEventListener('keydown', (e) => { else if (e.code === 'Space') pick('wait') else if (ACTION_KEYS[e.code]) pick(ACTION_KEYS[e.code]!) else if (e.code === 'Escape') { + // Cancels what is picked, or the flag; with nothing to cancel, opens the menu. + if (!selected && !destination) return showMenu() selected = null destination = null afterChange() diff --git a/src/style.css b/src/style.css index 2879045..61e9f97 100644 --- a/src/style.css +++ b/src/style.css @@ -278,3 +278,171 @@ img { font: inherit; font-weight: 800; } + +/* ---- Kenney UI pack: the font, the dialog, its buttons, and the menu button ---- */ + +@font-face { + font-family: 'Kenney Future'; + src: url('../assets/kenney-ui/Kenney Future.ttf') format('truetype'); + font-display: swap; +} + +/* The top bar's items are buttons that open their info. */ +#hud .hud-item { + display: flex; + align-items: center; + padding: 2px; + border: 0; + border-radius: 8px; + background: none; + color: inherit; + font: inherit; + cursor: pointer; +} + +#hud .hud-item:active { + background: rgb(255 255 255 / 0.15); +} + +#hud .hud-item + .hud-item { + margin-left: 4px; +} + +#menu-button { + display: grid; + place-content: center; + gap: 3px; + width: 40px; + height: 40px; + margin-left: auto; + padding: 0 0 3px; + border: 0; + background: url('../assets/kenney-ui/Grey/button_round_depth_flat.png') center / contain no-repeat; + cursor: pointer; +} + +#menu-button span { + width: 16px; + height: 3px; + border-radius: 2px; + background: var(--ink); +} + +#dialog { + max-width: min(360px, calc(100vw - 32px)); + overflow: visible; + padding: 0; + border: 0; + background: none; + color: var(--ink); +} + +#dialog::backdrop { + background: rgb(53 53 65 / 0.6); +} + +/* The RPG expansion's parchment panel, stretched by its edges (9-slice). */ +#dialog .panel { + position: relative; + display: grid; + justify-items: center; + gap: 10px; + padding: 14px 18px 18px; + border: 12px solid transparent; + border-image: url('../assets/kenney-ui-rpg/panel_beigeLight.png') 12 fill / 12px stretch; + text-align: center; +} + +#dialog .panel:focus { + outline: none; +} + +#dialog-close { + position: absolute; + top: -26px; + right: -26px; + width: 40px; + height: 40px; + padding: 0; + border: 0; + background: + url('../assets/kenney-ui/Grey/icon_cross.png') center 45% / 16px no-repeat, + url('../assets/kenney-ui/Grey/button_round_depth_flat.png') center / contain no-repeat; + cursor: pointer; +} + +#dialog-icon { + width: 64px; + height: 64px; +} + +#dialog-title { + margin: 0; + font: 22px/1.2 'Kenney Future', ui-rounded, system-ui, sans-serif; +} + +#dialog-body { + font-size: 15px; + line-height: 1.4; +} + +#dialog-body p { + margin: 0 0 8px; +} + +#dialog-body .credit { + font-size: 12px; + color: var(--muted); +} + +#dialog-body a { + color: inherit; +} + +/* Keyboard focus in the dialog: a yellow ring, like the count badges. */ +#dialog button:focus-visible { + outline: 3px solid #ffc83d; + outline-offset: 2px; +} + +#dialog-buttons { + display: grid; + gap: 8px; + width: 100%; +} + +/* The UI pack's rectangle buttons, stretched by their edges; the darker strip at the bottom is + the button's depth, which it sinks into when pressed. */ +.ui-button { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 52px; + padding: 0 12px; + border-style: solid; + border-width: 12px 12px 16px; + border-color: transparent; + border-image: url('../assets/kenney-ui/Green/button_rectangle_depth_flat.png') 24 24 32 fill / 12px 12px 16px stretch; + background: none; + color: #fff; + font: 16px 'Kenney Future', ui-rounded, system-ui, sans-serif; + cursor: pointer; +} + +.ui-button.danger { + border-image-source: url('../assets/kenney-ui/Red/button_rectangle_depth_flat.png'); +} + +.ui-button.plain { + border-image-source: url('../assets/kenney-ui/Grey/button_rectangle_depth_flat.png'); + color: var(--ink); +} + +.ui-button:active { + transform: translateY(3px); +} + +.ui-button img { + height: 20px; +} diff --git a/src/ui/controls.ts b/src/ui/controls.ts index 1f1f3a8..53e3d8f 100644 --- a/src/ui/controls.ts +++ b/src/ui/controls.ts @@ -2,6 +2,7 @@ import { ACTIONS, ACTION_ORDER, chargeOf, isAvailable } from '../sim/actions' import { PAR_BONUS, START_LIVES } from '../sim/state' import type { ActionId, GameState, PowerUpKind, Powers } from '../sim/types' import { CHARGE_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites' +import type { HudItem } from './info' /** The HTML around the board: HUD, action bar, and the banner shown between attempts and levels. */ export interface Handlers { @@ -9,6 +10,9 @@ export interface Handlers { pick(id: ActionId): void /** The banner's button: continue after losing a life or clearing a level, or a new run after game over or the ending. */ banner(): void + /** A top-bar item was tapped: show what it means. */ + info(item: HudItem): void + menu(): void } export const KEYS: Record<ActionId, string> = { @@ -32,6 +36,11 @@ const PASSES: [keyof Powers, PowerUpKind, string][] = [ /** What a death takes away (the NES rule; Skates are kept). */ const LOST_ON_DEATH = PASSES.filter(([p]) => p !== 'run') +/** A top-bar item: a button that opens its info (`data-info`, see ui/info.ts). */ +function item(info: HudItem, html: string): string { + return `<button type="button" class="hud-item" data-info="${info}">${html}</button>` +} + function icon(name: string, className = 'icon'): string { return `<img class="${className}" src="${spriteUrl(name)}" alt="" draggable="false">` } @@ -63,6 +72,13 @@ export function createControls(handlers: Handlers) { const bannerButton = document.getElementById('banner-button') as HTMLButtonElement bannerButton.addEventListener('click', () => handlers.banner()) + const hudEl = document.getElementById('hud')! + hudEl.addEventListener('click', (e) => { + const b = (e.target as Element).closest<HTMLElement>('[data-info]') + if (b) handlers.info(b.dataset.info as HudItem) + }) + document.getElementById('menu-button')!.addEventListener('click', () => handlers.menu()) + const hud = { lives: document.getElementById('hud-lives')!, level: document.getElementById('hud-level')!, @@ -97,21 +113,26 @@ export function createControls(handlers: Handlers) { const hearts = Array.from({ length: Math.max(s.lives, START_LIVES) }, (_, i) => icon(i < s.lives ? 'hud_heart' : 'hud_heart_empty'), ) - hud.lives.innerHTML = `<span class="sr-only">${s.lives} lives</span>${hearts.join('')}` - hud.level.innerHTML = icon('flag_green_a') + number(s.level, 'Level ') - hud.enemies.innerHTML = icon('slime_normal_rest') + number(s.enemies.length, 'Enemies left ') - hud.coins.innerHTML = icon('hud_coin') + number(s.coins, 'Coins ') + hud.lives.innerHTML = item('lives', `<span class="sr-only">${s.lives} lives</span>${hearts.join('')}`) + hud.level.innerHTML = item('level', icon('flag_green_a') + number(s.level, 'Level ')) + hud.enemies.innerHTML = item('enemies', icon('slime_normal_rest') + number(s.enemies.length, 'Enemies left ')) + hud.coins.innerHTML = item('coins', icon('hud_coin') + number(s.coins, 'Coins ')) // Turns taken, and the par to beat for the bonus. - hud.turn.innerHTML = - `<small aria-hidden="true">Turn</small>` + number(s.turn, 'Turn ') + `<small class="par">/${s.par}</small>` + hud.turn.innerHTML = item( + 'turn', + `<small aria-hidden="true">Turn</small>` + number(s.turn, 'Turn ') + `<small class="par">/${s.par}</small>`, + ) // Bomb limit, blast size and fuse, then a small icon for each pass held and the Star's turns. const passes = PASSES.filter(([p]) => s.powers[p]) - .map(([, kind, name]) => `<span class="sr-only">, ${name}</span>${icon(POWER_UP_ICONS[kind], 'icon small')}`) + .map(([p, kind, name]) => item(p, `<span class="sr-only">${name}</span>${icon(POWER_UP_ICONS[kind], 'icon small')}`)) .join('') - const star = s.star > 0 ? icon('star', 'icon small') + number(s.star, ', Star for ') : '' + const star = s.star > 0 ? item('star', icon('star', 'icon small') + number(s.star, 'Star for ')) : '' hud.stats.innerHTML = - icon('bomb') + number(s.stats.capacity, 'Bombs ') + icon('fireball') + number(s.stats.fire, ', fire ') + - icon('torch_on_a') + number(s.stats.fuse, ', fuse ') + passes + star + item('bombs', icon('bomb') + number(s.stats.capacity, 'Bombs ')) + + item('fire', icon('fireball') + number(s.stats.fire, 'Blast ')) + + item('fuse', icon('torch_on_a') + number(s.stats.fuse, 'Fuse ')) + + passes + + star banner.hidden = s.status === 'playing' if (s.status === 'dead') { diff --git a/src/ui/dialog.ts b/src/ui/dialog.ts new file mode 100644 index 0000000..61527dd --- /dev/null +++ b/src/ui/dialog.ts @@ -0,0 +1,65 @@ +/** + * The one modal dialog: info about something tapped, the menu, and confirmations. A native + * `<dialog>` shown with `showModal()`, so it blocks the board, traps focus and closes on Esc. + * Styled with Kenney's UI packs (style.css). + */ + +export interface DialogButton { + label: string + /** primary: green; danger: red; plain: grey. */ + kind: 'primary' | 'danger' | 'plain' + /** An image URL shown before the label. */ + icon?: string + /** Runs after the dialog closes. */ + onClick?: () => void +} + +export interface DialogContent { + /** Image URL for the picture at the top. */ + icon?: string + title: string + /** Trusted HTML built by the game itself (never from user input). */ + body: string + buttons: DialogButton[] +} + +const dialog = document.getElementById('dialog') as HTMLDialogElement +const icon = document.getElementById('dialog-icon') as HTMLImageElement +const title = document.getElementById('dialog-title')! +const body = document.getElementById('dialog-body')! +const buttons = document.getElementById('dialog-buttons')! +const panel = dialog.querySelector<HTMLElement>('.panel')! + +document.getElementById('dialog-close')!.addEventListener('click', () => dialog.close()) +// A tap on the dimmed backdrop (the dialog element itself, outside its panel) closes it. +dialog.addEventListener('click', (e) => { + if (e.target === dialog) dialog.close() +}) + +export function openDialog(content: DialogContent): void { + icon.hidden = !content.icon + if (content.icon) icon.src = content.icon + title.textContent = content.title + body.innerHTML = content.body + buttons.replaceChildren( + ...content.buttons.map((b) => { + const el = document.createElement('button') + el.type = 'button' + el.className = `ui-button ${b.kind}` + el.innerHTML = (b.icon ? `<img src="${b.icon}" alt="">` : '') + `<span>${b.label}</span>` + el.addEventListener('click', () => { + dialog.close() + b.onClick?.() + }) + return el + }), + ) + if (dialog.open) dialog.close() + dialog.showModal() + // Focus the panel, not the first button, so nothing looks pre-selected; Tab still reaches the buttons. + panel.focus() +} + +export function dialogOpen(): boolean { + return dialog.open +} diff --git a/src/ui/info.ts b/src/ui/info.ts new file mode 100644 index 0000000..c584a52 --- /dev/null +++ b/src/ui/info.ts @@ -0,0 +1,201 @@ +import { DECOY_TURNS, TELEPORT_RANGE } from '../sim/actions' +import { LAST_LEVEL } from '../sim/levelgen' +import { bombAt, cellAt, eq, exitOpen, isBurning, PAR_BONUS } from '../sim/state' +import { COIN_BAG, EXIT_SPAWN, STAR_TURNS } from '../sim/turn' +import type { ChargeId, Dir, EnemyKind, GameState, Powers, PowerUpKind, Vec } from '../sim/types' +import { dirBetween } from '../sim/enemies' +import { CHARGE_ICONS, coinSprite, ENEMY_SPRITES, POWER_UP_ICONS, spriteUrl } from '../render/sprites' + +/** + * What the game says about each thing on the board and in the top bar, for the info dialog + * (tap anything that isn't an action). Plain words, numbers from the current state. + */ +export interface Info { + /** Sprite name, from `assets/kenney/`. */ + icon: string + title: string + /** Paragraphs of plain text. */ + body: string[] +} + +export const ENEMY_TEXT: Record<EnemyKind, { name: string; moves: string }> = { + blob: { name: 'Blob', moves: 'Wanders, usually carrying on the way it was going.' }, + seeker: { name: 'Seeker', moves: 'Half the time it takes the shortest way toward you (or your decoy); otherwise it wanders like a Blob.' }, + drifter: { name: 'Drifter', moves: 'Goes straight until something blocks it, then turns.' }, +} + +export const POWER_UP_TEXT: Record<PowerUpKind, { name: string; text: string }> = { + bombPlus: { name: 'Bomb +1', text: 'You can have one more bomb on the board at a time.' }, + firePlus: { name: 'Fire +1', text: 'Your blasts reach one tile further in each direction.' }, + fusePlus: { name: 'Fuse +1', text: 'Your bombs start one higher: one more turn to get away.' }, + skates: { name: 'Skates', text: 'Gives you Run: 2 tiles in a straight line in one turn. Both tiles must be empty. Kept when you die.' }, + detonator: { name: 'Detonator', text: 'Gives you Detonate: pick one of your bombs and it goes off this turn. Lost when you die.' }, + wallPass: { name: 'Wall Pass', text: 'You can walk through crates. Lost when you die.' }, + bombPass: { name: 'Bomb Pass', text: 'You can walk over bombs. Lost when you die.' }, + flamePass: { name: 'Flame Pass', text: 'Flame can’t hurt you (enemies still can). Lost when you die.' }, + star: { name: 'Star', text: `Nothing can hurt you for ${STAR_TURNS} turns.` }, + coinBag: { name: 'Coin bag', text: `${COIN_BAG} coins.` }, + heart: { name: 'Heart', text: 'One more life.' }, +} + +export const CHARGE_TEXT: Record<ChargeId, { name: string; text: string }> = { + kick: { name: 'Kick', text: 'Push a bomb next to you: it slides away until something blocks it, keeping its number.' }, + throw: { name: 'Throw', text: 'Put a bomb exactly 2 tiles away in a straight line, over anything in between.' }, + shield: { name: 'Shield', text: 'The next hit (flame or enemy) is absorbed, and anything else that hits you that turn.' }, + freeze: { name: 'Freeze', text: 'Every enemy skips its next move.' }, + fuse: { name: 'Fuse ±1', text: 'Add 1 to any bomb’s number (up to 9), or take 1 off (down to 1).' }, + diagonal: { name: 'Diagonal', text: 'Step one tile diagonally, even between two blocked tiles.' }, + teleport: { name: 'Teleport', text: `Jump to any empty tile up to ${TELEPORT_RANGE} steps away, over anything.` }, + pierce: { name: 'Pierce', text: 'A bomb whose flame goes through crates, burning every one in reach.' }, + power: { name: 'Power', text: 'A bomb whose flame reaches all the way to the walls (crates still stop it).' }, + line: { name: 'Line', text: 'Put as many bombs as you’re allowed in a row, starting next to you.' }, + decoy: { name: 'Decoy', text: `A copy of you that Seekers chase instead, for ${DECOY_TURNS} turns. Flame destroys it.` }, +} + +const ARROWS: Record<Dir, string> = { up: '↑', down: '↓', left: '←', right: '→' } +const PUNISH = `Don’t blast it once it’s uncovered: flame on it lets out ${EXIT_SPAWN} more enemies.` + +/** The top bar's items. */ +export type HudItem = 'lives' | 'level' | 'enemies' | 'coins' | 'turn' | 'bombs' | 'fire' | 'fuse' | 'star' | keyof Powers + +const PASS_KIND: Record<keyof Powers, PowerUpKind> = { + run: 'skates', detonate: 'detonator', wallPass: 'wallPass', bombPass: 'bombPass', flamePass: 'flamePass', +} + +export function hudInfo(item: HudItem, s: GameState): Info { + switch (item) { + case 'lives': + return { + icon: 'hud_heart', + title: `Lives: ${s.lives}`, + body: [ + 'Caught by flame or touched by an enemy, you lose a life and the level starts again, same layout and enemies.', + 'With no lives left, the run is over.', + ], + } + case 'level': + return { icon: 'flag_green_a', title: `Level ${s.level} of ${LAST_LEVEL}`, body: [`Clear all ${LAST_LEVEL} levels to win the run. New enemy types join every few levels.`] } + case 'enemies': + return { + icon: 'slime_normal_rest', + title: `Enemies left: ${s.enemies.length}`, + body: ['The exit only opens when this reaches 0.', 'Tap an enemy to see what it does.'], + } + case 'coins': + return { + icon: 'hud_coin', + title: `Coins: ${s.coins}`, + body: ['From enemies, coin bags, and finishing a level under par. They will buy upgrades in the shop (coming soon).'], + } + case 'turn': + return { + icon: 'hud_character_0', + title: `Turn ${s.turn}, par ${s.par}`, + body: [`Turns taken on this level. Finish it in ${s.par} turns or fewer for a bonus of ${PAR_BONUS} coins.`], + } + case 'bombs': + return { + icon: 'bomb', + title: `Bombs: ${s.stats.capacity} at a time`, + body: ['How many of your bombs can be on the board at once. Bomb +1 (blue gem) adds one.'], + } + case 'fire': + return { + icon: 'fireball', + title: `Blast: ${s.stats.fire} ${s.stats.fire === 1 ? 'tile' : 'tiles'}`, + body: ['How far your blasts reach in each of the four directions. Fire +1 (red gem) adds one.'], + } + case 'fuse': + return { + icon: 'torch_on_a', + title: `Fuse: ${s.stats.fuse}`, + body: [`The number your new bombs start at: they explode after ${s.stats.fuse} more of your actions. Fuse +1 (yellow gem) adds one.`], + } + case 'star': + return { icon: 'star', title: `Star: ${s.star} turns left`, body: ['Nothing can hurt you until it runs out.'] } + default: { + const kind = PASS_KIND[item] + return { icon: POWER_UP_ICONS[kind], title: POWER_UP_TEXT[kind].name, body: [POWER_UP_TEXT[kind].text] } + } + } +} + +/** + * What is on a tile, most important first, or null for plain empty floor (a tap there is + * auto-move, not info). + */ +export function tileInfo(s: GameState, p: Vec): Info | null { + const enemies = s.enemies.filter((e) => eq(e.pos, p)) + const e = enemies[0] + if (e) { + const moves = e.plan.map((to, i) => ARROWS[dirBetween(i ? e.plan[i - 1]! : e.pos, to)]).join(' ') + const more = enemies.length > 1 ? ` (and ${enemies.length - 1} more on this tile)` : '' + return { + icon: ENEMY_SPRITES[e.kind].a, + title: ENEMY_TEXT[e.kind].name + more, + body: [ + ENEMY_TEXT[e.kind].moves, + moves + ? `Its next moves: ${moves}. The arrows beside it show them, next move on top. It makes exactly those moves unless a bomb gets in its way.` + : 'It has nowhere to go right now, so it stays put (eyes closed).', + 'Flame kills it, including flame still burning from last turn. Touching it costs you a life.', + ], + } + } + const bomb = bombAt(s, p) + if (bomb) { + const kind = bomb.special === 'pierce' ? 'Pierce bomb' : bomb.special === 'power' ? 'Power bomb' : 'Bomb' + const reach = bomb.special === 'power' ? 'to the walls' : `${bomb.fire} ${bomb.fire === 1 ? 'tile' : 'tiles'}` + return { + icon: 'bomb', + title: `${kind}: ${bomb.timer}`, + body: [ + `Explodes at the end of your ${bomb.timer === 1 ? 'next action' : `${bomb.timer}th action from now`}.`, + `Its blast reaches ${reach} in each direction. Flame stops at walls${bomb.special === 'pierce' ? ' (it goes through crates)' : ' and on the first crate'}, and sets off any bomb it reaches.`, + ], + } + } + if (s.decoy && eq(s.decoy.pos, p)) { + return { icon: 'character_green_front', title: 'Decoy', body: [`Seekers chase it instead of you for ${s.decoy.turns} more ${s.decoy.turns === 1 ? 'turn' : 'turns'}. Flame destroys it.`] } + } + if (isBurning(s, p)) { + return { + icon: 'fireball', + title: 'Still burning', + body: ['Flame from last turn’s blast. It goes out at the end of this turn. Anything in it then dies, and you can’t step in.'], + } + } + const cell = cellAt(s, p) + if (cell === 'wall') return { icon: 'block_blue', title: 'Wall', body: ['Indestructible: nothing gets through, not even flame.'] } + if (cell === 'block') { + return { + icon: 'block_planks', + title: 'Crate', + body: ['Flame destroys it (and stops there). On every level one crate hides the exit and another hides a power-up.'], + } + } + if (s.powerUp && eq(s.powerUp.pos, p)) { + const t = POWER_UP_TEXT[s.powerUp.kind] + return { icon: POWER_UP_ICONS[s.powerUp.kind], title: t.name, body: [t.text, 'Walk onto it to take it. ' + PUNISH] } + } + if (eq(s.exit, p)) { + return exitOpen(s) + ? { icon: 'door_open', title: 'Exit: open', body: ['Step onto it to finish the level.', PUNISH] } + : { + icon: 'door_closed', + title: 'Exit: locked', + body: [`It opens when every enemy is gone (${s.enemies.length} left). Then step onto it to finish the level.`, PUNISH], + } + } + const drop = s.drops.find((d) => eq(d.pos, p)) + if (drop) { + if (drop.kind === 'coins') return { icon: coinSprite(drop.amount), title: `${drop.amount} ${drop.amount === 1 ? 'coin' : 'coins'}`, body: ['Walk onto it to take it. A blast destroys it.'] } + const t = CHARGE_TEXT[drop.kind] + return { icon: CHARGE_ICONS[drop.kind], title: `${t.name} charge`, body: [t.text, 'Walk onto it to take one. Using it takes your turn. A blast destroys it.'] } + } + return null +} + +export function infoIconUrl(info: Info): string { + return spriteUrl(info.icon) +} diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index 60a99ce..1c51cc6 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -72,6 +72,18 @@ const SHOTS = [ query: 'seed=55', act: press('KeyB', 'KeyS', 'KeyD', 'KeyD', 'Space', 'KeyA', 'KeyA', 'KeyS', 'KeyS', 'KeyR'), }, + // Tapping an enemy, or anything in the top bar, explains it. + { name: 'info-enemy', act: tap(4, 5) }, + { name: 'info-hud', act: async (page) => { await page.click('[data-info=fuse]') } }, + // The menu (top right), and the confirmation before abandoning a run. + { name: 'menu', act: async (page) => { await page.click('#menu-button') } }, + { + name: 'new-run', + act: async (page) => { + await page.click('#menu-button') + await page.click('#dialog-buttons .danger') + }, + }, // Holding action charges: their buttons appear; Throw picked shows where a bomb can land. { name: 'charges', query: 'charges=2', act: async (page) => { await page.click('[data-action=throw]') } }, ]