explodorama / commit
Fewer buttons, Kick/Throw/Teleport as abilities, a real Freeze, almanac, badged icons
PLAN.md | 94 ++++--- README.md | 90 ++++--- assets/composed/icon_fuse.png | Bin 0 -> 1820 bytes assets/composed/icon_move.png | Bin 0 -> 1182 bytes assets/composed/icon_wait.png | Bin 0 -> 1316 bytes assets/composed/powerup_bomb_pass.png | Bin 0 -> 6289 bytes assets/composed/powerup_bomb_plus.png | Bin 0 -> 3197 bytes assets/composed/powerup_detonator.png | Bin 0 -> 3133 bytes assets/composed/powerup_fire_plus.png | Bin 0 -> 6391 bytes assets/composed/powerup_flame_pass.png | Bin 0 -> 5720 bytes assets/composed/powerup_fuse_plus.png | Bin 0 -> 7595 bytes assets/composed/powerup_kick.png | Bin 0 -> 3120 bytes assets/composed/powerup_run.png | Bin 0 -> 2212 bytes assets/composed/powerup_teleport.png | Bin 0 -> 2251 bytes assets/composed/powerup_throw.png | Bin 0 -> 3180 bytes assets/composed/powerup_wall_pass.png | Bin 0 -> 7267 bytes assets/kenney-board-icons/License.txt | 22 ++ assets/kenney-board-icons/character.png | Bin 0 -> 932 bytes assets/kenney-board-icons/hourglass.png | Bin 0 -> 1046 bytes assets/kenney-board-icons/shield.png | Bin 0 -> 913 bytes assets/kenney-board-icons/timer_CW_75.png | Bin 0 -> 1562 bytes assets/kenney-icons/White/arrowRight.png | Bin 0 -> 15282 bytes assets/kenney-icons/White/cross.png | Bin 0 -> 15480 bytes assets/kenney-icons/White/exclamation.png | Bin 0 -> 15304 bytes assets/kenney-icons/White/export.png | Bin 0 -> 15268 bytes assets/kenney-icons/White/fastForward.png | Bin 0 -> 15352 bytes assets/kenney-icons/White/larger.png | Bin 0 -> 15389 bytes assets/kenney-icons/White/plus.png | Bin 0 -> 15215 bytes assets/kenney-icons/White/upRight.png | Bin 0 -> 15407 bytes assets/kenney-ui/Green/button_round_depth_flat.png | Bin 0 -> 2476 bytes assets/kenney/bush.png | Bin 2145 -> 0 bytes assets/kenney/gem_blue.png | Bin 1436 -> 0 bytes assets/kenney/gem_green.png | Bin 1421 -> 0 bytes assets/kenney/gem_red.png | Bin 1434 -> 0 bytes assets/kenney/gem_yellow.png | Bin 1394 -> 0 bytes assets/kenney/ladder_top.png | Bin 589 -> 0 bytes assets/kenney/sign_right.png | Bin 1316 -> 0 bytes assets/kenney/spring_out.png | Bin 2361 -> 0 bytes assets/kenney/torch_off.png | Bin 1331 -> 0 bytes assets/kenney/torch_on_a.png | Bin 2724 -> 0 bytes assets/kenney/window.png | Bin 1843 -> 0 bytes decisions.md | 155 +++++++++++ docs/screenshots/almanac-charges.png | Bin 0 -> 363678 bytes docs/screenshots/almanac-enemies.png | Bin 0 -> 300151 bytes docs/screenshots/almanac.png | Bin 0 -> 432463 bytes docs/screenshots/auto-move.png | Bin 247225 -> 247153 bytes docs/screenshots/bomb-picked.png | Bin 247215 -> 263510 bytes docs/screenshots/charges.png | Bin 274858 -> 287254 bytes docs/screenshots/countdown.png | Bin 252035 -> 249116 bytes docs/screenshots/death.png | Bin 196151 -> 193047 bytes docs/screenshots/desktop.png | Bin 599115 -> 583475 bytes docs/screenshots/dev.png | Bin 265446 -> 262006 bytes docs/screenshots/enemies.png | Bin 250737 -> 247378 bytes docs/screenshots/info-enemy.png | Bin 189332 -> 183045 bytes docs/screenshots/info-hud.png | Bin 179736 -> 173449 bytes docs/screenshots/info-mode.png | Bin 246730 -> 243316 bytes docs/screenshots/lingering.png | Bin 251484 -> 249196 bytes docs/screenshots/long-press.png | Bin 250372 -> 247665 bytes docs/screenshots/menu.png | Bin 187294 -> 173505 bytes docs/screenshots/move-picked.png | Bin 0 -> 306483 bytes docs/screenshots/new-run.png | Bin 191939 -> 184871 bytes docs/screenshots/pierce-decoy.png | Bin 282447 -> 265585 bytes docs/screenshots/power-up.png | Bin 254037 -> 251463 bytes docs/screenshots/run.png | Bin 237669 -> 168533 bytes docs/screenshots/scrolled.png | Bin 257554 -> 252804 bytes docs/screenshots/small-desktop.png | Bin 281514 -> 271844 bytes docs/screenshots/small.png | Bin 192002 -> 186025 bytes docs/screenshots/start.png | Bin 246163 -> 243519 bytes index.html | 21 +- src/main.ts | 85 ++++-- src/render/board.ts | 72 +++-- src/render/effects.ts | 37 ++- src/render/sprites.ts | 72 +++-- src/sim/actions.ts | 39 +-- src/sim/enemies.ts | 13 +- src/sim/levelgen.ts | 8 +- src/sim/picks.ts | 43 +++ src/sim/record.ts | 2 +- src/sim/state.ts | 25 +- src/sim/turn.ts | 5 +- src/sim/types.ts | 43 +-- src/style.css | 300 +++++++++++++++------ src/ui/almanac.ts | 71 +++++ src/ui/controls.ts | 178 ++++++++---- src/ui/dialog.ts | 3 + src/ui/info.ts | 99 +++++-- test/charges.test.ts | 33 +-- test/enemies.test.ts | 60 +++-- test/helpers.ts | 2 +- test/picks.test.ts | 96 +++++++ test/powerups.test.ts | 39 ++- tools/compose-icons.sh | 49 ++++ tools/screenshots.mjs | 27 +- 93 files changed, 1349 insertions(+), 434 deletions(-) diff --git a/PLAN.md b/PLAN.md index b320692..3b3921c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -22,7 +22,7 @@ turn), §15 (notes for the implementer) and §18 (milestones). Reasons for decis | 6 | Name | **Explodorama.** ("Bomberman" is a Konami trademark — never use it in the UI, title, manifest or repo name.) | | 7 | Shop purchases | **Last for the current run only.** Permanent progress comes from the Workshop (§7), which is a later milestone. | | 8 | Permanent upgrades | **Workshop between runs** (Everspace-style): coins are deposited into upgrade tracks whose tiers cost 5, 10, 15, … coins. See §7. | -| 9 | Death | **The player chooses** after each death: **Keep** (keep Bomb+, Fire+, Skates) or **Fresh** (lose them, get coins). Balanced so most players would pick each about half the time. See §9. | +| 9 | Death | **The player chooses** after each death: **Keep** (keep Bomb+, Fire+, Run) or **Fresh** (lose them, get coins). Balanced so most players would pick each about half the time. See §9. | | 10 | Curses | **Exist, but only appear after the first shop** (level 2 onward). See §6. | | 11 | Reapers / turn limit | **Later** (post-MVP). The par-turn coin bonus stays. | | 12 | Run length | **50 levels with an ending**, like the NES. A win produces a **victory certificate with a QR code** for that seed. See §11. | @@ -98,16 +98,34 @@ 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). | +**Few buttons, icons only, explained when picked (user request, 2026-10-22; built).** The bar +has Move, Bomb and Wait, plus a second row only for what is held: Shield, Freeze, Decoy. +*(Detonate and Fuse had buttons there too until a follow-up the same day: the Detonator now +works by tapping a bomb, which pulses, and the Fuse ±1 charge is gone.)* Nothing but Wait goes +off from its button alone: Shield and Freeze wait in the strip for a **Use** tap (or Enter). Buttons show icons, no names. Upgrades don't add buttons, they widen Move and +Bomb (`sim/picks.ts`): Move also kicks (Kick: walking into a bomb), runs (Run), teleports +(Teleport) and steps diagonally (a Diagonal charge); Bomb also throws (Throw) and detonates +(Detonator; a plain tap on a bomb does too, before a kick). The tile tapped +decides which, in that order of preference — a kick before walking onto a bomb with Bomb Pass, +anything free before spending a charge. Pierce, Power and Line are choices in Bomb's strip. Picking an action opens a **strip above the bar** saying what it does now, +listing the upgrades held (`actionInfo` in `ui/info.ts`). The old keys stay (R, T, P, G, I, O, L pick that one +action). With an action picked, a direction key uses the tile next to the player, or 2 away +if that one can't be used (a run, a throw). + **Info and menu (user request, 2026-10-21; built; changed 2026-10-22):** a dialog explains a tile (`ui/info.ts`): enemies (their next moves in words), bombs, burning flame, walls and crates, the exit, the power-up, drops. *Since 2026-10-22 (user request)* a plain tap no longer shows it: -the **?** button beside Wait (key **?**) turns on "What is that?" for one tap on the board, like -picking an action; tapping **?** again, picking an action, or Esc turns it off. Without it, a tap +the round **?** button at the top right, beside the menu (key **?**; it was beside Wait until +the button rework the same day), turns on "What is that?" for one tap on the board or on an +action button; tapping **?** again, picking an action, or Esc turns it off. Without it, a tap on an enemy, a bomb or burning flame does nothing (never a move into it), a wall or crate only clears the auto-move flag, and any other tile the player can walk to — including the exit, the power-up and drops — sets the auto-move destination. Things the player can walk to get a **Walk there** button in their dialog. Top-bar items still explain themselves on a plain tap. A **menu** button (top right; Esc with nothing to -cancel) shows level and seed, credits Kenney, and offers **New run** behind a confirmation. +cancel) shows level and seed, credits Kenney, and offers **New run** behind a confirmation and +an **Almanac** (2026-10-22, user request; `ui/almanac.ts`): tabs for power-ups, charges and +enemies, each with its sprite and the same text as "What is that?" (enemies also: the level +they first appear on). **Touch shortcuts (user request, 2026-10-20; built):** @@ -128,32 +146,36 @@ cancel) shows level and seed, credits Kenney, and offers **New run** behind a co and Go falls back to picking Move. **With no enemies left, auto-move walks by itself** (user request, 2026-10-22): one step 1 s after the last action, until the flag is reached — not while an action is picked, "What is - that?" is on or a dialog is open. **With Skates, a straight 2-tile stretch is one Run** + that?" is on or a dialog is open. **With the Run power-up, a straight 2-tile stretch is one Run** (same request), if both tiles pass the safety check; still recorded as an ordinary Run. ### 3.2 Abilities (permanent once gained — no charges) | Action | Source | Rule | |---|---|---| -| **Run** | Skates pickup (NES "Speed"), or shop | 2 tiles in a straight line. **Both** tiles must be free — no jumping over anything. Highlight: path tile + landing tile. An enemy on the path tile = death (you ran into it). | -| **Detonate** | Detonator pickup (NES "Detonator"), or shop | Explode one chosen own bomb now. | +| **Run** | Run pickup (called Skates until 2026-10-22; NES "Speed"), or shop | 2 tiles in a straight line. **Both** tiles must be free — no jumping over anything. Highlight: path tile + landing tile. An enemy on the path tile = death (you ran into it). *Part of Move since 2026-10-22.* | +| **Kick** | Kick power-up (*was a charge until 2026-10-22*) | Push an adjacent bomb by walking into it; it slides until the next tile is not free, keeping its timer. Part of Move. | +| **Throw** | Throw power-up (*was a charge*) | A bomb **exactly 2 tiles away** in a straight line, over anything. Part of Bomb. | +| **Teleport** | Teleport power-up, rare (*was a charge*) | Move to any free tile within Manhattan distance 3, over anything. Part of Move. | +| **Detonate** | Detonator pickup (NES "Detonator"), or shop | Explode one chosen own bomb now. *Since 2026-10-22:* tap the bomb (all of them pulse while you have it); no button. | + +Run, Kick, Throw and Teleport are kept on death; Detonate is lost (the NES rule). ### 3.3 Action charges (consumable, dropped by slain enemies) -The button shows a count, e.g. "Kick ×2". Using one spends one charge and the turn. +The button shows a count, e.g. "Shield ×2". Using one spends one charge and the turn. *Since +2026-10-22* Kick, Throw and Teleport are abilities (§3.2), not charges, and Diagonal, Pierce, +Power and Line have no button of their own (§3.1). | Action | Rule | |---|---| -| **Kick** | Push an adjacent bomb; it slides until the next tile is not free. It keeps its timer. The escape hatch for boxing yourself in. | -| **Throw** | Place a bomb **exactly 2 tiles away** in a straight line, over a wall/soft block/bomb. Target must be free. | -| **Fuse +1 / Fuse −1** | Change any visible bomb's timer by 1 (minimum 1). One action; the direction is chosen at target time. *Built:* one Fuse charge shows two buttons, Fuse +1 and Fuse −1; either spends it. Maximum 9. | -| **Shield** | Absorb the next hit (flame or enemy). Shown as a ring around the player. Arming it costs the turn. *Built:* it also absorbs any further hit in the same turn it breaks (e.g. an enemy and then a flame), so one shield never fails halfway through a turn. | +| ~~**Fuse +1 / Fuse −1**~~ | *Removed 2026-10-22 (user request):* changing a bomb's number after placing it isn't from the original and didn't make sense to the user. The Fuse +1 **power-up** (new bombs start higher) stays. | +| **Shield** | Absorb the next hit (flame or enemy). Shown as a ring around the player. Arming it costs the turn. *Built:* it also absorbs any further hit in the same turn it breaks (e.g. an enemy and then a flame), so one shield never fails halfway through a turn. Confirmed with a **Use** tap (2026-10-22). | | **Diagonal step** | Move 1 tile diagonally. Both orthogonal neighbours may be blocked — it's a squeeze. | -| **Teleport** (rare) | Jump to any free tile within Manhattan distance 3. *Built:* drops at half the weight of each other charge. | | **Pierce bomb** | Place a bomb whose flame passes **through** soft blocks (destroying all in range). From later Bomberman games. | | **Power bomb** | Place a bomb with maximum fire (whole row/column up to hard walls). *Built:* soft blocks still stop it (Pierce is the one for those). | | **Line bomb** | Place all remaining bomb capacity in a straight line from the player, in one action. From *Super Bomberman*. | -| **Freeze** | All enemies skip their next move. *Built:* that is the move their arrows show when Freeze is used, i.e. this same turn's phase 2, so there is never a greyed-out state to show. | +| **Freeze** | All enemies skip their moves for **this turn and the next 3** (`FREEZE_TURNS` 4; *changed 2026-10-22, user request:* one skipped move only wasted the turn). Their plans wait; the arrows go faint and icy. Drawn as blue enemies in a block of ice that thaws a step each turn. Confirmed with a **Use** tap. | | **Decoy** | Place a decoy on an adjacent tile; chasing enemies target it instead of the player for 3 turns. Flame destroys it. *Built:* it blocks nothing; "3 turns" = 3 enemy plannings; one decoy at a time (a new one replaces it). | All charge actions can also appear in the shop (§8). @@ -190,11 +212,11 @@ The NES original had exactly 8 power-ups, one hidden per level under a soft bloc |---|---|---| | Bombs | **Bomb +1** (capacity) | yes | | Flames | **Fire +1** | yes | -| Speed (roller skates) | **Skates → unlocks Run** | yes | +| Speed (roller skates) | **Run** (called Skates until 2026-10-22) | yes | | Wallpass | **Wall Pass** — walk through soft blocks | no — always lost on death | | Detonator | **Detonator → unlocks Detonate** | no | | Bombpass | **Bomb Pass** — walk over bombs | no | -| Flamepass | **Flame Pass** — immune to flame (tune later if too strong) | no | +| Flamepass | **Flame Pass** — immune to flame (tune later if too strong); walk through it (with a Star too, since 2026-10-22) | no | | Mystery | **Star** — invincible for 8 turns | no (it's temporary anyway) | Additions: @@ -210,12 +232,15 @@ NES rules kept (they create the classic tension): - **Flame destroys an exposed pickup** — and spawns extra enemies, as in the NES. - **Flaming the exit door** spawns a group of enemies from it. - **The exit opens only when all enemies are dead.** -- **Skates are guaranteed on level 1 or 2** so every player learns Run early. +- **Run is guaranteed on level 1 or 2** so every player learns Run early. +- *Built (2026-10-22, user request):* picking one up opens a dialog saying what it does + (`powerUpInfo`), 0.45 s later, once the player has arrived and the pickup has risen away. - *Built (2026-10-20):* the pickup is under any crate except the exit's. Draw weights (to tune): Bomb+ 20, Fire+ 20, Fuse+ 6, Coin bag 12 (10 coins), Heart 4, Wall Pass 8, Bomb Pass 8, - Flame Pass 5, Detonator 9, Star 8 (8 turns). Skates are not in the draw; which of level 1 or 2 - gets them comes from the seed. Sprites: gems for Bomb+ (blue), Fire+ (red), Fuse+ (yellow), - Skates (green), §13. Until Keep / Fresh exists, a death keeps stats and Skates (as "Keep" + Flame Pass 5, Detonator 9, Star 8 (8 turns), and since 2026-10-22 Kick 8, Throw 8, Teleport 3. + Run is not in the draw; which of level 1 or 2 + gets them comes from the seed. Sprites: §13 (since 2026-10-22, a bomb or the astronaut with a + modifier badge for Bomb+, Fire+, Fuse+, Run, Kick and Throw; gems before). Until Keep / Fresh exists, a death keeps stats and Run (as "Keep" would). A Run collects what is on the tile it passes through. --- @@ -229,7 +254,7 @@ The user wants to start thinking about making power-ups **exclusive**: picking o - **Slots.** The player has a few power-up slots (e.g. 3). Taking a power-up with a free slot fills it; with all slots full, a dialog asks which one to give up (or to leave the new one). The dropped power-up stays on that tile, so a swap can be undone while still on the level. -- **What takes a slot.** Natural candidates: the abilities and passes (Skates / Run, Detonator, +- **What takes a slot.** Natural candidates: the abilities and passes (Run, Detonator, Wall / Bomb / Flame Pass), maybe later new ones (Kick as a permanent ability, Pierce bombs…). Open: do the stat gems (Bomb+, Fire+, Fuse+) also take slots (e.g. "Bomb+ ×2" in one slot, stacking within it), or stay uncapped as in the NES? Instant ones (Heart, Coin bag, Star) @@ -244,7 +269,7 @@ these, not paper over them): ("Keep: 💣2 🔥3 Run"). With slots, "Keep" keeps the slot loadout; Fresh's payout formula would need rethinking. 2. **The shop (§8)** sells abilities and stats; with slots, a purchase may also force a swap. -3. **The Workshop (§7)** has tracks like "Bomb satchel +1 starting capacity" and "Skates from +3. **The Workshop (§7)** has tracks like "Bomb satchel +1 starting capacity" and "Run from the start" — they'd become starting slot contents or extra slots ("+1 slot" is a natural Workshop track). 4. **NES rule: passes and Detonator are lost on death (§5, §9)** — with slots, perhaps nothing @@ -305,7 +330,7 @@ Like *Everspace*, where you spend between runs on more weapon slots or stronger | Purse | Start each run with +5 coins | +3 | | Prospector | +1 hidden pickup per level | +1 | | Curse Ward | First skull per level is harmless | 1 | -| Skates from the start | Start with Run | 1 | +| Run from the start | Start with Run | 1 | - Workshop state lives in `localStorage` (try/catch — it can be unavailable). Offer **export/import** of the save as text, because phones clear site data. @@ -321,7 +346,7 @@ Like *Everspace*, where you spend between runs on more weapon slots or stronger - **Shop after every level** (so the first shop follows level 1). A random selection, e.g. 4 offers, one paid reroll: - Stats: Bomb +1, Fire +1, Fuse +1, Heart - - Abilities: Skates (Run), Detonator, Bomb Pass, Wall Pass + - Abilities: Run, Detonator, Bomb Pass, Wall Pass - Action charges: any from §3.3, plus **Cleanse** (removes the current curse) - Run modifiers: **Magnet** (collect drops within 1 tile), **Lucky** (higher drop chance), **Map** (reveals the exit and hidden pickup each level), **Coin multiplier** @@ -337,7 +362,7 @@ enemies reset). Before that, a choice screen with two buttons, **both showing ex | | **Keep** | **Fresh** | |---|---|---| -| Bomb +, Fire +, Fuse +, Skates (Run) | kept | **reset to starting values** | +| Bomb +, Fire +, Fuse +, Run | kept | **reset to starting values** | | Wall Pass, Bomb Pass, Flame Pass, Detonator, Star | lost (NES rule) | lost | | Coins | kept | kept, **plus a coin payout** | | Action charges | kept | kept | @@ -484,6 +509,14 @@ plan of black Icongeek26 outline icons from Flaticon over a plain drawn board. lack: `question` (black, 2x) on the "What is that?" button, inverted to white while it is on. Files in `assets/kenney-icons/` with the pack's licence, unchanged, under the pack's names. Credited with the others. +- **Badged power-ups (2026-10-22, user request):** a power-up that improves something you + already have is that thing plus a **modifier badge** at the top right — a green disc with a + dark rim and a white symbol. Symbols from Game Icons (`White/2x`: `plus`, `larger`, + `fastForward`, `arrowRight`, `upRight`, in `assets/kenney-icons/White/`) and Kenney's **Board + Game Icons** pack (`timer_CW_75`, `shield`, in `assets/kenney-board-icons/` with its licence; + credited); later also `export`, `exclamation`, `cross` from Game Icons. + `tools/compose-icons.sh` (ImageMagick) builds them into `assets/composed/`, committed, so the + build needs nothing extra; `sprites.ts` loads both folders. Sprite assignments (✅ = drawn now; the rest are copied and waiting for their feature): @@ -496,15 +529,15 @@ Sprite assignments (✅ = drawn now; the rest are copied and waiting for their f | ✅ bomb | `bomb`; flashes to `bomb_active` on its last turn; timer drawn in `hud_character_0`–`9` | | ✅ flame | `fireball` (spinning, fading) over an orange tile | | ✅ HUD | `hud_heart` / `hud_heart_empty` (lives), `flag_green_a` (level), `slime_normal_rest` (enemies left), `hud_coin` (coins), `bomb`, `fireball`, digits | -| ✅ action buttons | Move `character_green_walk_a`, Bomb `bomb`, Wait `character_green_duck` | +| ✅ action buttons | Move `icon_move` (Board Game Icons `character`), Bomb `bomb`, Wait `icon_wait` (`hourglass`; `character_remove` briefly), both recoloured to ink by `tools/compose-icons.sh` and turned white on dark or green (class `mono`); until 2026-10-22 the astronaut's walking and crouching frames, which looked like the player. Icons only since 2026-10-22. "What is that?" (top right): Game Icons `question` | | ✅ banners | `character_green_hit` (lost a life), `hud_heart_empty` (game over), `flag_green_a` (level cleared); favicon `bomb` | | ✅ exit (locked / open) | `door_closed` + `door_closed_top`, `door_open` + `door_open_top`, squeezed into one tile. (`lock_yellow` spare.) | | ✅ coins | `coin_bronze` (1), `coin_silver` (2), `coin_gold` (5), bobbing; `hud_coin` in the HUD | -| ✅ action charges | Kick `spring_out`, Throw `sign_right`, Shield `switch_blue`, Freeze `chain`, Fuse ± `torch_on_a`, Diagonal `ramp`, Teleport `window`, Pierce `spikes`, Power `weight`, Line `bridge_logs`, Decoy `hud_player_helmet_green` (drops, buttons; Pierce / Power also as a badge on their bombs) | +| ✅ action charges | Shield `switch_blue`, Freeze `chain`, Diagonal `ramp`, Pierce `spikes`, Power `weight`, Line `bridge_logs`, Decoy `hud_player_helmet_green` (drops, buttons; Pierce / Power also as a badge on their bombs) | | ✅ decoy | `character_green_front`, faded | | ✅ auto-move flag | `flag_green_a` | | ✅ intent arrows | drawn red lines with a head, not sprites | -| ✅ power-ups | `gem_blue` Bomb+, `gem_red` Fire+, `gem_yellow` Fuse+, `gem_green` Skates (and the Run button), `lever` Detonator (and Detonate), `bush` Wall Pass, `ladder_top` Bomb Pass, `torch_off` Flame Pass, `star` Star (and a gold ring), `heart` Heart, `hud_coin` Coin bag; on a yellow glow. HUD: `torch_on_a` fuse, small pass icons. | +| ✅ power-ups | badged (`assets/composed/`): `powerup_bomb_plus` Bomb+ (bomb + plus), `powerup_fire_plus` Fire+ (bomb + larger), `powerup_fuse_plus` Fuse+ (bomb + timer), `powerup_run` Run (the Move figure + fast-forward), `powerup_kick` Kick (bomb + arrow right), `powerup_throw` Throw (bomb + arrow up-right), `powerup_teleport` Teleport (Move figure + outbox), `powerup_detonator` Detonator (bomb + !), `powerup_wall_pass` Wall Pass (crate + ✕), `powerup_bomb_pass` Bomb Pass (bomb + shield), `powerup_flame_pass` Flame Pass (fireball + shield); `star` Star (and a gold ring), `heart` Heart, `hud_coin` Coin bag; on a yellow glow. (Replaced and removed on 2026-10-22: the gems, `spring_out`, `sign_right`, `bush`, `ladder_top`, `torch_off`, `window`.) HUD: `icon_fuse` fuse (the Fuse +1 badge's timer, ink, white on the bar; the torch `torch_on_a` until 2026-10-22), small pass icons. | | ✅ Blob | `slime_normal_rest` (nowhere to go), `_walk_a` / `_walk_b` alternating; mirrored when heading right. `_flat` spare (the death animation flips `_rest` over instead, the same for every enemy). | | ✅ Seeker | `fly_rest`, `fly_a`, `fly_b`, as the Blob | | ✅ Drifter | `saw_rest`, `saw_a`, `saw_b`; spins while moving | @@ -529,7 +562,7 @@ and state (`GameState.options`), so replays are exact. Shown as tags in the top | Option | Rules | |---|---| | **Board: Normal** | 31 × 13, density 35%, 50 levels, enemies 3 + 1 per 3 levels (max 10), par 50 + 10/enemy. | -| **Board: Small** | 9 × 11 (fits a phone whole; the camera scales to fit both ways, no scrolling), density 25%, **20 levels**, enemies 2 + 1 per 4 levels (max 5), par 25 + 8/enemy. Same enemy-type schedule and Skates rule. Levelgen tops up crates if fewer than 2 are far enough for the exit and power-up. | +| **Board: Small** | 9 × 11 (fits a phone whole; the camera scales to fit both ways, no scrolling), density 25%, **20 levels**, enemies 2 + 1 per 4 levels (max 5), par 25 + 8/enemy. Same enemy-type schedule and Run rule. Levelgen tops up crates if fewer than 2 are far enough for the exit and power-up. | | **Developer** | Every ability and pass, 5 bombs, blast 5, unlimited charges (never spent; badges show ∞). Re-applied every level and after every death. **Killable** (the user split it from God mode). Second-row buttons pack five to a row when there are more than 8. | | **God** | The player can't be hurt at all. | | **Crumbling crates** | When the last enemy dies, every crate gets a countdown of 1–3 turns (seeded, `hash(seed, "crumble", level)`), shown by shaking and fading; at 0 it is gone, revealing whatever was under it. It is not flame: no punishment, nothing burned. | @@ -614,6 +647,7 @@ src/ state.ts create/clone state, grid helpers levelgen.ts seeded level generation (§11) actions.ts registry: { id, isAvailable(state), validTargets(state), apply(state, target) } + picks.ts what a tap means with Move or Bomb picked (they stand for groups of actions) turn.ts step(state, action, target) → { state, events[] } — the 5 phases of §1 explosions.ts blast shapes, chain reactions enemies.ts intent planning per enemy type @@ -687,7 +721,7 @@ tools/ icons.mjs (PWA icons), screenshots.mjs (Playwright) charge drops, coins, charge inventory (buttons with counts), and all 11 charge actions (2026-10-20). Touch shortcuts from the user's playtest: tap self = Wait, hold = Bomb, tap far = auto-move (§3.1). -7. **M6 – Run loop:** ✅ NES pickups (all 11, one hidden per level, Skates on level 1 or 2), +7. **M6 – Run loop:** ✅ NES pickups (all 11, one hidden per level, Run on level 1 or 2), Run and Detonate, the passes and Star, 50-level progression and ending, mid-run save (2026-10-20). ⏳ Shop after each level, Keep/Fresh death choice, curses from level 2. 8. **M7 – Look & feel:** ✅ animations (2026-10-22: everything that appears, moves, changes or diff --git a/README.md b/README.md index 8b2d758..16a412e 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,21 @@ hidden under one of the crates, and step onto it. Each turn you pick **one** action; then the world takes its turn, in this order: enemies step where their arrows pointed, bombs count down and explode, and enemies show their next step. +The action buttons are **icons only** (a game-piece figure = Move, a bomb = Bomb, an hourglass += Wait). **Picking one shows a strip above the buttons that explains what it does +right now** — it lists the upgrades you have, so it grows as you pick them up. Upgrades don't +add buttons: they make Move and Bomb do more. + | Action | Phone | Keyboard | What it does | |---|---|---|---| -| **Move** | Tap a tile next to you | W A S D or arrow keys | Step one tile up, down, left or right onto an empty tile. | -| **Bomb** | Tap **Bomb**, then a highlighted tile — or **press and hold** a tile next to you | **B**, then a direction | Place a bomb on an empty tile next to you. You can have 1 bomb on the board at a time. | +| **Move** | Tap a tile next to you, or tap **Move** and then any highlighted tile | W A S D or arrow keys | Step one tile up, down, left or right onto an empty tile. **With the Run power-up:** or 2 tiles in a straight line. **With Teleport:** or any empty tile up to 3 steps away, over anything. **With Kick:** walking into a bomb kicks it. **With Diagonal charges:** or a diagonal step (uses one). | +| **Bomb** | Tap **Bomb**, then a highlighted tile — or **press and hold** a tile next to you | **B**, then a direction | Place a bomb on an empty tile next to you. You can have 1 bomb on the board at a time. **With Throw:** or 2 tiles away in a straight line, over anything. **With Pierce / Power / Line charges:** pick one of them in the strip first. | | **Wait** | Tap **Wait**, or tap **yourself** | Space | Do nothing; the world takes its turn. | +When Move or Bomb can do several things, the tile you tap decides: next to you is a step (or a +kick, into a bomb), 2 straight is a run, further is a teleport; next to you is a bomb, 2 away a +throw. A Diagonal charge is only spent when nothing free reaches that tile. + **Auto-move:** tap any tile further away that you can walk to — empty floor, the exit, the power-up or a drop. A flag marks it, dots show the way, and the **Move** button becomes **Go**: each press of Go — or each tap on the flag itself — takes **one @@ -43,14 +52,15 @@ It **won't take a deadly step**: onto an enemy, onto a tile an enemy's arrow poi 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). **Once every enemy is gone**, the flag needs no more presses: you walk there by yourself, one -step a second (still one turn per step). **With Skates**, a step of auto-move where the way goes +step a second (still one turn per step). **With the Run power-up**, a step of auto-move where the way goes straight on for 2 tiles is a Run instead (one turn for both), if both tiles are safe. Tapping an enemy, a bomb, burning flame, a wall or a crate does nothing (apart from clearing the -auto-move flag, for a wall or crate) — a tap never walks into an enemy. +auto-move flag, for a wall or crate; and with Kick, tapping a bomb next to you kicks it) — a tap +never walks into an enemy. -**What is that?** Tap the **?** button beside Wait (or press **?**), then tap anything on the board -to see what it is and what it does: an **enemy** (with its next moves spelled out), a **bomb**, +**What is that?** Tap the round **?** button at the top right, below the menu on a phone and beside it on a wider screen (or press **?**), +then tap anything on the board — or any action button — 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. The **?** turns green while it is on; it works for one tap, and tapping it again or **Esc** turns it off. For things you can walk to, the dialog has a **Walk there** button that sets the auto-move flag. The items in the **top @@ -58,28 +68,30 @@ bar** (lives, level, enemies left, coins, turn / par, bombs, blast, fuse, passes themselves with a plain tap. **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. +and seed, and has the **Almanac** — every power-up, action charge and enemy type, with its +picture and what it does, in three tabs — and **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. -**Action charges** are one-use actions that enemies sometimes drop. Their buttons appear in a -second row, with a badge showing how many you hold; using one spends one and takes your turn. +**Action charges** are one-use actions that enemies sometimes drop; using one spends one and +takes your turn. Shield, Freeze and Decoy get their own icon button in a second row, with +a badge showing how many you hold. **Nothing goes off from a button alone** (except Wait): +Shield and Freeze open the strip, which explains them and has a **Use** button to confirm (or +press **Enter**); Decoy then wants a tile. Diagonal, Pierce, Power and Line have no button: +they are part of Move and Bomb. | Charge | Icon | Phone | Keyboard | What it does | |---|---|---|---|---| -| **Kick** | spring | Tap **Kick**, then a bomb next to you | **K**, then a direction | The bomb slides away from you until the next tile is blocked (a wall, crate, bomb or enemy). It keeps its number. | -| **Throw** | arrow sign | Tap **Throw**, then a highlighted tile | **T**, then a direction | Place a bomb exactly 2 tiles away in a straight line, over anything in between. Uses your bomb limit like a normal bomb. | -| **Shield** | blue dome | Tap **Shield** | **H** | A ring appears round you. The next hit (flame or enemy) is absorbed, and so is anything else that hits you in that same turn. | -| **Freeze** | chain | Tap **Freeze** | **F** | Every enemy skips the step its arrow shows this turn. | -| **Fuse +1 / Fuse −1** | torch | Tap **Fuse +1** or **Fuse −1**, then any bomb | **=** or **−**, then tap a bomb | Add 1 to a bomb's number (up to 9) or take 1 off (down to 1). One Fuse charge gives both buttons; using either spends it. | -| **Diagonal** | ramp | Tap **Diagonal**, then a highlighted tile | **G**, then **Q E Z C** (up-left, up-right, down-left, down-right) | Step one tile diagonally — even squeezing between two blocked tiles. | -| **Teleport** (rare) | stone archway | Tap **Teleport**, then a highlighted tile | **P**, then tap a tile | Jump to any empty tile within 3 steps (counting across and down), over anything. | -| **Pierce** | spikes | Tap **Pierce**, then a highlighted tile | **I**, then a direction | A bomb whose flame goes **through** crates, burning every one in reach. Marked with a spikes badge. | -| **Power** | weight | Tap **Power**, then a highlighted tile | **O**, then a direction | A bomb whose flame reaches all the way to the walls (crates still stop it). Marked with a weight badge. | -| **Line** | logs | Tap **Line**, then a highlighted tile | **L**, then a direction | Places as many bombs as you're still allowed, in a row starting at that tile, until the row is blocked. | +| **Shield** | blue dome | Tap **Shield**, then **Use Shield** | **H**, then **Enter** | A ring appears round you. The next hit (flame or enemy) is absorbed, and so is anything else that hits you in that same turn. | +| **Freeze** | chain | Tap **Freeze**, then **Use Freeze** | **F**, then **Enter** | Every enemy freezes in place for **this turn and the next 3**: they turn blue inside a block of ice and thaw a step each turn, then carry on with the moves their (faded, icy) arrows show. | | **Decoy** | helmet | Tap **Decoy**, then a highlighted tile | **Y**, then a direction | A pale copy of you. Enemies that chase you (Seekers) chase it instead for 3 turns. Flame destroys it; it doesn't block anyone. | +| **Diagonal** | ramp | Tap **Move**, then a diagonal tile | **G**, then **Q E Z C** (up-left, up-right, down-left, down-right) | Step one tile diagonally — even squeezing between two blocked tiles. | +| **Pierce** | spikes | Tap **Bomb**, pick **Pierce** in the strip, then a tile | **I**, then a direction | A bomb whose flame goes **through** crates, burning every one in reach. Marked with a spikes badge. | +| **Power** | weight | Tap **Bomb**, pick **Power**, then a tile | **O**, then a direction | A bomb whose flame reaches all the way to the walls (crates still stop it). Marked with a weight badge. | +| **Line** | logs | Tap **Bomb**, pick **Line**, then a tile | **L**, then a direction | Places as many bombs as you're still allowed, in a row starting at that tile, until the row is blocked. | With an action picked, holding or hovering a highlighted tile previews what it will do: where you will end up, a faded bomb with its blast, a kicked bomb's stopping place, a changed number. @@ -100,7 +112,8 @@ will end up, a faded bomb with its blast, a kicked bomb's stopping place, a chan - A bomb caught in a blast **explodes at once**, whatever its number (a chain reaction). - **Flame lingers for one more turn.** After a blast, its tiles keep burning (orange, with flickering fireballs) through the next turn: any enemy that walks in dies, and so do you if - you wait in it. You can't step or put a bomb into burning tiles. It goes out after that turn. + you wait in it. You can't step or put a bomb into burning tiles — unless flame can't hurt you + (Flame Pass, a Star), then you can walk through it like floor. It goes out after that turn. - Bombs block movement, for you and for enemies. - Bombs can't be placed on an enemy. @@ -153,29 +166,35 @@ open, so pick them up soon. Coins will buy upgrades in the shop (not built yet). Every level hides **one power-up** under a crate (never the exit's). Burn the crate to reveal it — it sits on a yellow glow — then walk onto it. **Don't blast it once it's uncovered:** flame on -it destroys it and lets out 3 enemies, just like the exit. **Skates are always on level 1 or 2.** +it destroys it and lets out 3 enemies, just like the exit. **Run is always on level 1 or 2.** +When you pick one up, a dialog says what it does. | Power-up | Looks like | Effect | Lost when you die? | |---|---|---|---| -| **Bomb +1** | blue gem | One more bomb on the board at a time. | no | -| **Fire +1** | red gem | Blast reaches one tile further. | no | -| **Fuse +1** | yellow gem | Your bombs start one higher (more time to get away). | no | -| **Skates** | green gem | The **Run** action: 2 tiles in a straight line in one turn (**R**, then a direction). Both tiles must be empty; an enemy on the tile in between is run into. Anything on that tile is picked up. | no | -| **Detonator** | lever | The **Detonate** action: pick one of your bombs (**X**, then tap it); it goes off this turn. | yes | -| **Wall Pass** | bush | Walk through crates. | yes | -| **Bomb Pass** | ladder | Walk over bombs. | yes | -| **Flame Pass** | unlit torch | Flame doesn't hurt you (enemies still do). | yes | -| **Star** | star | Nothing hurts you for 8 turns (a gold ring; the top bar counts down). | yes | +| **Bomb +1** | bomb with a + badge | One more bomb on the board at a time. | no | +| **Fire +1** | bomb with an expanding-arrows badge | Blast reaches one tile further. | no | +| **Fuse +1** | bomb with a timer badge | Your bombs start one higher (more time to get away). | no | +| **Run** | the Move figure with a fast-forward badge | **Move** can run: 2 tiles in a straight line in one turn (or **R**, then a direction). Both tiles must be empty; an enemy on the tile in between is run into. Anything on that tile is picked up. | no | +| **Kick** | bomb with a right-arrow badge | Walk into a bomb next to you and it slides away until the next tile is blocked (a wall, crate, bomb or enemy), keeping its number. | no | +| **Throw** | bomb with an up-right-arrow badge | **Bomb** can also go exactly 2 tiles away in a straight line, over anything in between (or **T**, then a direction). | no | +| **Teleport** (rare) | the Move figure with an outbox badge | **Move** can take you to any empty tile within 3 steps (counting across and down), over anything (or **P**, then tap a tile). | no | +| **Detonator** | bomb with a ! badge | Your bombs pulse with a red glow: **tap one** and it goes off this turn (or **X**, then tap it). No button. | yes | +| **Wall Pass** | crate with an ✕ badge | Walk through crates. | yes | +| **Bomb Pass** | bomb with a shield badge | Walk over bombs. | yes | +| **Flame Pass** | flame with a shield badge | Flame doesn't hurt you (enemies still do), and you can walk through it. | yes | +| **Star** | star | Nothing hurts you for 8 turns (a gold ring; the top bar counts down); you can walk through flame meanwhile. | yes | | **Heart** | heart | One more life. | — | | **Coin bag** | coin | 10 coins. | — | -Run and Detonate appear as buttons in the second row once you have them. The top bar shows -your fuse and a small icon for each pass you hold. +Run, Kick, Throw and Teleport (user request, 2026-10-22: they were one-use charges before) +add no buttons — they make Move and Bomb do more, and the strip says how. The Detonator adds +none either: your bombs pulse, and a tap on one sets it off. The top bar shows your fuse and a small icon for each +ability and pass you hold. ### Lives You have **3 lives**. Caught in a blast or touched by an enemy, you lose one and the level starts -again with the same layout and enemies. You keep your coins, charges, stats and Skates, but lose +again with the same layout and enemies. You keep your coins, charges, stats and Run, but lose Wall Pass, Bomb Pass, Flame Pass, the Detonator and a Star (as in the original); the banner says what you lost. With none left, the run is over and the seed is shown, so the same board can be replayed. @@ -250,8 +269,9 @@ Design and decisions: [PLAN.md](PLAN.md), [decisions.md](decisions.md). 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) and -[Game Icons](https://kenney.nl/assets/game-icons), all by +[UI Pack: RPG Expansion](https://kenney.nl/assets/ui-pack-rpg-expansion), +[Game Icons](https://kenney.nl/assets/game-icons) and +[Board Game Icons](https://kenney.nl/assets/board-game-icons), 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 (in the menu). Thank you, Kenney! Support his work at <https://www.kenney.nl/donate>. diff --git a/assets/composed/icon_fuse.png b/assets/composed/icon_fuse.png new file mode 100644 index 0000000..b6e5849 Binary files /dev/null and b/assets/composed/icon_fuse.png differ diff --git a/assets/composed/icon_move.png b/assets/composed/icon_move.png new file mode 100644 index 0000000..d6f6ac8 Binary files /dev/null and b/assets/composed/icon_move.png differ diff --git a/assets/composed/icon_wait.png b/assets/composed/icon_wait.png new file mode 100644 index 0000000..8b61b5d Binary files /dev/null and b/assets/composed/icon_wait.png differ diff --git a/assets/composed/powerup_bomb_pass.png b/assets/composed/powerup_bomb_pass.png new file mode 100644 index 0000000..dd778a6 Binary files /dev/null and b/assets/composed/powerup_bomb_pass.png differ diff --git a/assets/composed/powerup_bomb_plus.png b/assets/composed/powerup_bomb_plus.png new file mode 100644 index 0000000..73802f6 Binary files /dev/null and b/assets/composed/powerup_bomb_plus.png differ diff --git a/assets/composed/powerup_detonator.png b/assets/composed/powerup_detonator.png new file mode 100644 index 0000000..d864637 Binary files /dev/null and b/assets/composed/powerup_detonator.png differ diff --git a/assets/composed/powerup_fire_plus.png b/assets/composed/powerup_fire_plus.png new file mode 100644 index 0000000..400d0ea Binary files /dev/null and b/assets/composed/powerup_fire_plus.png differ diff --git a/assets/composed/powerup_flame_pass.png b/assets/composed/powerup_flame_pass.png new file mode 100644 index 0000000..44a1609 Binary files /dev/null and b/assets/composed/powerup_flame_pass.png differ diff --git a/assets/composed/powerup_fuse_plus.png b/assets/composed/powerup_fuse_plus.png new file mode 100644 index 0000000..d3399bf Binary files /dev/null and b/assets/composed/powerup_fuse_plus.png differ diff --git a/assets/composed/powerup_kick.png b/assets/composed/powerup_kick.png new file mode 100644 index 0000000..0fe9e29 Binary files /dev/null and b/assets/composed/powerup_kick.png differ diff --git a/assets/composed/powerup_run.png b/assets/composed/powerup_run.png new file mode 100644 index 0000000..7494309 Binary files /dev/null and b/assets/composed/powerup_run.png differ diff --git a/assets/composed/powerup_teleport.png b/assets/composed/powerup_teleport.png new file mode 100644 index 0000000..513e8e6 Binary files /dev/null and b/assets/composed/powerup_teleport.png differ diff --git a/assets/composed/powerup_throw.png b/assets/composed/powerup_throw.png new file mode 100644 index 0000000..167d5bc Binary files /dev/null and b/assets/composed/powerup_throw.png differ diff --git a/assets/composed/powerup_wall_pass.png b/assets/composed/powerup_wall_pass.png new file mode 100644 index 0000000..2783ff4 Binary files /dev/null and b/assets/composed/powerup_wall_pass.png differ diff --git a/assets/kenney-board-icons/License.txt b/assets/kenney-board-icons/License.txt new file mode 100644 index 0000000..29893e5 --- /dev/null +++ b/assets/kenney-board-icons/License.txt @@ -0,0 +1,22 @@ + + + Board Game Icons (1.1) + + Created/distributed by Kenney (www.kenney.nl) + Creation date: 22-07-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-board-icons/character.png b/assets/kenney-board-icons/character.png new file mode 100644 index 0000000..10087b1 Binary files /dev/null and b/assets/kenney-board-icons/character.png differ diff --git a/assets/kenney-board-icons/hourglass.png b/assets/kenney-board-icons/hourglass.png new file mode 100644 index 0000000..42cdc6d Binary files /dev/null and b/assets/kenney-board-icons/hourglass.png differ diff --git a/assets/kenney-board-icons/shield.png b/assets/kenney-board-icons/shield.png new file mode 100644 index 0000000..4e3dea0 Binary files /dev/null and b/assets/kenney-board-icons/shield.png differ diff --git a/assets/kenney-board-icons/timer_CW_75.png b/assets/kenney-board-icons/timer_CW_75.png new file mode 100644 index 0000000..62fb18c Binary files /dev/null and b/assets/kenney-board-icons/timer_CW_75.png differ diff --git a/assets/kenney-icons/White/arrowRight.png b/assets/kenney-icons/White/arrowRight.png new file mode 100644 index 0000000..f0c041b Binary files /dev/null and b/assets/kenney-icons/White/arrowRight.png differ diff --git a/assets/kenney-icons/White/cross.png b/assets/kenney-icons/White/cross.png new file mode 100644 index 0000000..e21d5ac Binary files /dev/null and b/assets/kenney-icons/White/cross.png differ diff --git a/assets/kenney-icons/White/exclamation.png b/assets/kenney-icons/White/exclamation.png new file mode 100644 index 0000000..951d06f Binary files /dev/null and b/assets/kenney-icons/White/exclamation.png differ diff --git a/assets/kenney-icons/White/export.png b/assets/kenney-icons/White/export.png new file mode 100644 index 0000000..97100c7 Binary files /dev/null and b/assets/kenney-icons/White/export.png differ diff --git a/assets/kenney-icons/White/fastForward.png b/assets/kenney-icons/White/fastForward.png new file mode 100644 index 0000000..6500b10 Binary files /dev/null and b/assets/kenney-icons/White/fastForward.png differ diff --git a/assets/kenney-icons/White/larger.png b/assets/kenney-icons/White/larger.png new file mode 100644 index 0000000..4f5d733 Binary files /dev/null and b/assets/kenney-icons/White/larger.png differ diff --git a/assets/kenney-icons/White/plus.png b/assets/kenney-icons/White/plus.png new file mode 100644 index 0000000..3f5cf37 Binary files /dev/null and b/assets/kenney-icons/White/plus.png differ diff --git a/assets/kenney-icons/White/upRight.png b/assets/kenney-icons/White/upRight.png new file mode 100644 index 0000000..244dc9b Binary files /dev/null and b/assets/kenney-icons/White/upRight.png differ diff --git a/assets/kenney-ui/Green/button_round_depth_flat.png b/assets/kenney-ui/Green/button_round_depth_flat.png new file mode 100644 index 0000000..ac7cb3b Binary files /dev/null and b/assets/kenney-ui/Green/button_round_depth_flat.png differ diff --git a/assets/kenney/bush.png b/assets/kenney/bush.png deleted file mode 100644 index 62fed53..0000000 Binary files a/assets/kenney/bush.png and /dev/null differ diff --git a/assets/kenney/gem_blue.png b/assets/kenney/gem_blue.png deleted file mode 100644 index 7cd3ec8..0000000 Binary files a/assets/kenney/gem_blue.png and /dev/null differ diff --git a/assets/kenney/gem_green.png b/assets/kenney/gem_green.png deleted file mode 100644 index 7ccce2e..0000000 Binary files a/assets/kenney/gem_green.png and /dev/null differ diff --git a/assets/kenney/gem_red.png b/assets/kenney/gem_red.png deleted file mode 100644 index 953761c..0000000 Binary files a/assets/kenney/gem_red.png and /dev/null differ diff --git a/assets/kenney/gem_yellow.png b/assets/kenney/gem_yellow.png deleted file mode 100644 index 1e7acfd..0000000 Binary files a/assets/kenney/gem_yellow.png and /dev/null differ diff --git a/assets/kenney/ladder_top.png b/assets/kenney/ladder_top.png deleted file mode 100644 index c9fd03a..0000000 Binary files a/assets/kenney/ladder_top.png and /dev/null differ diff --git a/assets/kenney/sign_right.png b/assets/kenney/sign_right.png deleted file mode 100644 index dbce9af..0000000 Binary files a/assets/kenney/sign_right.png and /dev/null differ diff --git a/assets/kenney/spring_out.png b/assets/kenney/spring_out.png deleted file mode 100644 index 8b98d0e..0000000 Binary files a/assets/kenney/spring_out.png and /dev/null differ diff --git a/assets/kenney/torch_off.png b/assets/kenney/torch_off.png deleted file mode 100644 index 8c54d19..0000000 Binary files a/assets/kenney/torch_off.png and /dev/null differ diff --git a/assets/kenney/torch_on_a.png b/assets/kenney/torch_on_a.png deleted file mode 100644 index 13256d9..0000000 Binary files a/assets/kenney/torch_on_a.png and /dev/null differ diff --git a/assets/kenney/window.png b/assets/kenney/window.png deleted file mode 100644 index 462cdc8..0000000 Binary files a/assets/kenney/window.png and /dev/null differ diff --git a/decisions.md b/decisions.md index 8571b9b..87b50cb 100644 --- a/decisions.md +++ b/decisions.md @@ -334,3 +334,158 @@ bombs teleported), with flame fading out over about half a second. to act directly and to put an enemy in a blast, removed afterwards): bomb pop, kick slide, throw arc, shield ring, crate burst, enemy death, flame fading, teleport. +### 2026-10-22 — Fewer action buttons; Kick, Throw and Teleport become abilities + +The user found there were too many action buttons and the "?" odd among them. Asked first: +charges or permanent, kick on a plain tap, how far to fold, where to explain. + +- **"?" moves to the top bar**, a round button beside the menu. The two are stacked in a column + at the right of the top bar on a phone (beside its two rows), side by side on a wide screen — + in a row with the items, the menu would have wrapped onto a third row. +- **Icons only.** The names are in the explanation strip, the tooltip and the screen-reader + label. Key hints stay on wide screens. +- **Upgrades widen Move and Bomb instead of adding buttons** (`sim/picks.ts`). Move: step, kick + (walking into a bomb), run (Skates), teleport, diagonal step (a charge). Bomb: place, throw. + The tile tapped decides. Preference order: a kick before walking onto the bomb with Bomb Pass + (the user's "walking into a bomb kicks it"; a bomb with no room to slide is still walked over), + a plain step before a run, anything free before spending a charge. +- **Kick, Throw and Teleport are permanent for the run** (user's choice, over "spend a charge" + or "for the level"). Made by me: they are **power-ups now, hidden under crates like Skates** + (draw weights Kick 8, Throw 8, Teleport 3), not enemy drops — permanent things are power-ups + in this game, and as drops every run would have collected all three within a few levels. + **Kept on death**, like Skates ("permanent for the run"). They no longer drop from enemies; + the remaining 8 charges are equally likely (Teleport's half weight went with it). + `SIM_VERSION` 7: saves from before don't resume. +- **Kick on a plain tap, always** (user's choice): tapping a bomb next to you, or an arrow key + into it, kicks it once you have Kick. +- **Folded too (user's choice): Diagonal, Pierce, Power, Line.** They stay one-use charges (the + user chose "permanent" only for Kick, Throw and Teleport). Diagonal steps are Move targets + while you hold one; Pierce, Power and Line are chips in Bomb's strip, each with its count. + **Fuse +1 and −1 became one Fuse button** with +1 / −1 chips. Shield, Freeze, Decoy and + Detonate keep icon buttons, in a second row of up to five. +- **The explanation strip** (user's choice: above the action bar) sits over the board's bottom + edge, so the board doesn't move when it appears. It lists only the upgrades held, so picking + an action teaches what it can do now. Shield and Freeze, which happen at once, show theirs + for 2.5 s after use; Wait shows none (it is pressed every few turns) — "?" then Wait explains + it, as "?" then any button does. +- **Not yet:** small upgrade badges on Move and Bomb (the user: "that can wait"). +- **Checked in a browser** (Developer mode, a temporary read-only hook, removed): Bomb's strip; + a tap 2 away throws; Move teleports 3 tiles; the Pierce chip places a Pierce bomb; the Fuse + −1 chip; "?" then Wait explains Wait; Shield's strip after use; an arrow into a bomb kicks it + (found walking over it with Bomb Pass, hence the kick-first order). + +### 2026-10-22 — Playtest: Detonator by tapping, Use to confirm, no Fuse charge, a real Freeze + +- **On a phone the menu sits above "?"** (`column-reverse`); side by side on a wide screen, as + the user liked it. +- **The Detonator works by tapping a bomb** (user request): while you have it, every bomb pulses + with a red glow, and a tap on one sets it off this turn. Its button is gone; Bomb's group now + includes Detonate too. **A plain tap on a bomb next to you detonates rather than kicks** (user's + choice): you still kick by walking into it — an arrow key, or Move picked and then the bomb. +- **Nothing but Wait goes off from its button** (user request: Shield went off at once). Shield and + Freeze open the strip with a **Use** chip, like Bomb's choices; Enter confirms too; pressing + the button again cancels. Wait stays one tap (user's choice: it spends nothing and is pressed + every few turns). Decoy already wanted a tile. The 2.5 s after-use explanation went with it. +- **The Fuse ±1 charge is removed** (user's choice). Asked whether it was in the original: no — + the 1985 game had fixed fuses and no fuse power-up at all; both Fuse +1 (the power-up, kept) + and Fuse ±1 (the charge) were this game's additions. 7 charges remain, equally likely. +- **Fire is walkable while it can't hurt you** (user report: with Flame Pass, tapping the burning + tiles round them did nothing, while the Move button worked). The tap rule refused burning tiles + outright; now it, `playerCanEnter` and auto-move's safety check all go by `flameSafe`: Flame + Pass, a Star or God mode. A Star now lets you step into fire too (it couldn't before). +- **Freeze lasts this turn and the next 3** (user request: one skipped move just wasted the + turn). `frozen` is now a count. Auto-move ignores frozen enemies' arrows. Drawn with LittleJS's + colour tinting (the renderer is Canvas 2D, so no shaders: multiply towards ice blue plus an + added cold glow), a see-through ice block with a rim and a glint, and twinkling sparkles; it + thaws a step each turn over 0.5 s, with a little crack-shake, and the arrows go faint and icy + until it is over. +- `SIM_VERSION` stays 7: the previous change hasn't been committed or played anywhere yet. +- **Checked in a browser** (Developer mode, temporary hook, removed): Shield waits for Use; Freeze + holds the nearest enemy in place for 4 turns (frozen 3, 2, 1, 0) and then it moves, with + frames of the ice thawing; a bomb pulses and a tap detonates it; with Flame Pass a tap on a + burning tile next to you walks into it. + +### 2026-10-22 — A dialog when a power-up is picked up + +User request. Taking a power-up opens the info dialog for it ("You picked it up!" and what it +does, from the same texts as "What is that?"). It opens 0.45 s after the step, so the walk and +the pickup's rise-away animation are seen first; auto-move pauses while it is open, as for any +dialog. Every power-up gets one, Heart and Coin bag included. Charge and coin drops don't: they +are frequent, and their buttons and the top bar show them. Checked in a browser (a Kick +power-up placed next to the player through a temporary hook, removed): closed at 0.2 s, open +with "Kick" by 0.9 s, the ability gained. + +### 2026-10-22 — The almanac + +User request: to see every power-up and enemy (some sprites are to be replaced). **Menu → +Almanac**: one dialog with three tabs — Power-ups (all 14, in reading order: stats, abilities, +passes, the rest), Charges (all 7) and Enemies (the 3 built, each with the level it first +appears on). Each entry is the sprite plus the same words as "What is that?", so there is one +text per thing. Tabs swap the list without closing the dialog. Sprite file names aren't shown in +the game; for picking replacements, labelled sheets of every sprite (name and file) were made +outside the repo. Screenshots: `almanac`, `almanac-enemies`. + +### 2026-10-22 — Almanac scrolling; badged power-up icons + +- **The almanac didn't scroll with a wheel or trackpad**: LittleJS cancels every wheel event on + the page. The dialog now stops wheel events from reaching it (`dialog.ts`), so long dialogs + scroll. **Its scroll bar is hidden** (the user saw scroll bars on desktop); the cut-off last + entry shows there is more. Measured on a 1280 × 720 window: a wheel turn scrolled 300 px, + where it scrolled 0 before. +- **Almanac screenshots show whole lists**: one per tab (`almanac`, `almanac-charges`, + `almanac-enemies`), taken in a 390 × 2100 window. +- **Power-ups that improve something you have show that thing plus a modifier badge** (user + request, with the icons named by the user): Bomb +1 bomb + plus, Fire +1 bomb + expanding + arrows, Fuse +1 bomb + a three-quarter timer (from Board Game Icons, which the user added), + Skates the walking astronaut + fast-forward, Kick bomb + arrow right, Throw bomb + arrow + up-right. Made by me: the badge's look (green disc, dark rim, white symbol, 56 px of 128, top + right), and building the icons ahead of time with a script (`tools/compose-icons.sh`) rather + than drawing two sprites wherever an icon appears — the board, the top bar, dialogs and the + almanac all take one picture. The replaced sprites (four gems, `spring_out`, `sign_right`) are + no longer used and were removed from `assets/kenney/`. +- **Watch for:** a bomb-shaped power-up lying on the floor could be mistaken for a real bomb, and + the Skates icon for the player. Both sit on the yellow power-up glow, carry the badge, bob, and + the bombs have no number; not changed until the user has seen them in play. +- **Detonator's text** was stale ("pick one of your bombs"): now "your bombs pulse: tap one". + +### 2026-10-22 — Move and Wait get their own icons + +User request: the astronaut on the Move button looked like the player. Move is now Board Game +Icons' `character` (a game-piece figure) and Wait its `character_remove` (the figure with an ✕: +stand still). Those icons are white, so `tools/compose-icons.sh` makes ink-coloured copies +(`icon_move`, `icon_wait`), and CSS turns them white again on a picked (green) button and in the +dark explanation strip. Skates was defined as "the Move icon plus fast-forward", so its badged +icon now uses the new figure too (made by me, for consistency). + +### 2026-10-22 — Badged icons for the rest of the abilities and passes + +User request, symbols named by the user: Teleport = the Move figure + an outbox (Game Icons +`export`), Detonator = bomb + `exclamation`, Wall Pass = crate (`block_planks`) + `cross`, Bomb +Pass = bomb + shield (Board Game Icons `shield`), Flame Pass = fireball + shield. Built by +`tools/compose-icons.sh` like the first six. `bush`, `ladder_top`, `torch_off` and `window` are +no longer used and were removed; `lever` stays (the "Run settings" info icon). Star, Heart and +Coin bag keep their plain sprites. + +### 2026-10-22 — The fuse shows the timer, not a torch + +User request: the lit torch beside the fuse number in the top bar (and on its info dialog) was +unclear. It is now the three-quarter timer of the Fuse +1 badge (Board Game Icons +`timer_CW_75`), made ink-coloured by `tools/compose-icons.sh` (`icon_fuse`) and turned white on +the dark top bar. `torch_on_a` is no longer used and was removed. +Also: the top bar's bombs, blast and fuse info still named the old gems ("Bomb +1 (blue gem) +adds one"); they now say "The Bomb +1 power-up adds one" and so on. + +### 2026-10-22 — Wait is an hourglass + +User request: the Wait button shows Board Game Icons' `hourglass` instead of the figure with an +✕. Made ink-coloured like the others (`icon_wait`), white on a dark or green background. +`character_remove` is no longer used and was removed from `assets/kenney-board-icons/`. + +### 2026-10-22 — "Skates" is called "Run" + +User request. The power-up that lets Move go 2 tiles straight is now **Run** everywhere: on +screen, in the almanac, the docs, and the code (the power-up's id `skates` → `run`, sprite +`powerup_run`, `skatesLevel` → `runLevel`). The seed hash that picks level 1 or 2 for it keeps +its old key `'skates'`, so every seed still has it on the same level. Earlier entries here keep +the old name, as they were written. + diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png new file mode 100644 index 0000000..259e715 Binary files /dev/null and b/docs/screenshots/almanac-charges.png differ diff --git a/docs/screenshots/almanac-enemies.png b/docs/screenshots/almanac-enemies.png new file mode 100644 index 0000000..8a43a8d Binary files /dev/null and b/docs/screenshots/almanac-enemies.png differ diff --git a/docs/screenshots/almanac.png b/docs/screenshots/almanac.png new file mode 100644 index 0000000..4cbebfc Binary files /dev/null and b/docs/screenshots/almanac.png differ diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png index 0dca10c..046f933 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 7e78c38..c6fa923 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 4cd2a3f..5363982 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 c21df9e..0a211cb 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 cde3447..0506ab4 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 a1d24ec..f653240 100644 Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ diff --git a/docs/screenshots/dev.png b/docs/screenshots/dev.png index f85e114..f1185a1 100644 Binary files a/docs/screenshots/dev.png and b/docs/screenshots/dev.png differ diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png index c1bf4c7..86100df 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 index 98e0a97..e982d0d 100644 Binary files a/docs/screenshots/info-enemy.png and b/docs/screenshots/info-enemy.png differ diff --git a/docs/screenshots/info-hud.png b/docs/screenshots/info-hud.png index 2de5635..c65e6df 100644 Binary files a/docs/screenshots/info-hud.png and b/docs/screenshots/info-hud.png differ diff --git a/docs/screenshots/info-mode.png b/docs/screenshots/info-mode.png index a2afdcc..eda83be 100644 Binary files a/docs/screenshots/info-mode.png and b/docs/screenshots/info-mode.png differ diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png index bb382d1..0da84a6 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 9e9e4da..ac85f03 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 index 0562254..bfa645a 100644 Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ diff --git a/docs/screenshots/move-picked.png b/docs/screenshots/move-picked.png new file mode 100644 index 0000000..c7f629d Binary files /dev/null and b/docs/screenshots/move-picked.png differ diff --git a/docs/screenshots/new-run.png b/docs/screenshots/new-run.png index c323a32..5c84371 100644 Binary files a/docs/screenshots/new-run.png and b/docs/screenshots/new-run.png differ diff --git a/docs/screenshots/pierce-decoy.png b/docs/screenshots/pierce-decoy.png index 2e5eb8d..4034fed 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 5feadae..c5fcb5c 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 97762f7..f67fa56 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 314650e..41d47cc 100644 Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ diff --git a/docs/screenshots/small-desktop.png b/docs/screenshots/small-desktop.png index 7c67467..85c3b91 100644 Binary files a/docs/screenshots/small-desktop.png and b/docs/screenshots/small-desktop.png differ diff --git a/docs/screenshots/small.png b/docs/screenshots/small.png index 9312f55..f5ca200 100644 Binary files a/docs/screenshots/small.png and b/docs/screenshots/small.png differ diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png index f1f1381..c53bfc7 100644 Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ diff --git a/index.html b/index.html index 066474e..50465d5 100644 --- a/index.html +++ b/index.html @@ -13,14 +13,19 @@ </head> <body> <header id="hud" aria-live="polite"> - <span id="hud-modes"></span> - <span id="hud-lives"></span> - <span id="hud-level"></span> - <span id="hud-enemies"></span> - <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> + <div id="hud-items"> + <span id="hud-modes"></span> + <span id="hud-lives"></span> + <span id="hud-level"></span> + <span id="hud-enemies"></span> + <span id="hud-coins"></span> + <span id="hud-turn"></span> + <span id="hud-stats"></span> + </div> + <div id="hud-buttons"> + <button id="info-button" type="button" aria-label="What is that?" aria-pressed="false"></button> + <button id="menu-button" type="button" aria-label="Menu"><span></span><span></span><span></span></button> + </div> </header> <nav id="actions" aria-label="Actions"></nav> <div id="banner" hidden> diff --git a/src/main.ts b/src/main.ts index b0802d7..ae9634d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,16 +15,18 @@ import { clearEffects, newEffects, pruneEffects, startEffects } from './render/e import { updateCamera } from './render/camera' import { TEXTURES } from './render/sprites' import { isAvailable, isValidTarget, ACTIONS } from './sim/actions' +import { canPick, resolvePick } from './sim/picks' 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 { bombAt, cellAt, eq, idx, inBounds, isBurning, newRun } from './sim/state' +import { bombAt, cellAt, eq, flameSafe, idx, inBounds, isBurning, newRun } from './sim/state' import { step } from './sim/turn' import { DEFAULT_OPTIONS, DIRS, type Action, type ActionId, type Dir, type GameState, type RunOptions, type Vec } from './sim/types' +import { openAlmanac } from './ui/almanac' import { createControls } from './ui/controls' import { dialogOpen, openDialog, type DialogButton } from './ui/dialog' -import { hudInfo, modeLines, tileInfo, type Info } from './ui/info' +import { actionInfo, hudInfo, modeLines, powerUpInfo, 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' @@ -166,6 +168,11 @@ function act(action: Action): void { enemySteps.clear() } if (e.type === 'enemyMoved') enemySteps.set(e.id, { from: e.from, at: time }) + // A power-up taken: say what it does, once it has risen away (render/effects.ts). + if (e.type === 'powerUpTaken') { + const { kind } = e.powerUp + window.setTimeout(() => showInfo(powerUpInfo(kind)), POWER_UP_DIALOG_MS) + } } selected = null inspecting = false @@ -173,6 +180,9 @@ function act(action: Action): void { afterChange() } +/** How long after taking a power-up its dialog opens: the walk there and the rise away first. */ +const POWER_UP_DIALOG_MS = 450 + function afterChange(): void { danger = predictDanger(state) // The walk is worked out again every turn, since bombs and crates change; reaching the @@ -191,7 +201,10 @@ function afterChange(): void { * - a tile next to the player: move there; * - any other tile you can walk to: set it as the auto-move destination; * - a wall or crate, or any floor you can't walk to: clears the flag if there is one; - * - an enemy, a bomb, or flame still burning: nothing, never a move into it. + * - one of the player's bombs, with the Detonator: set it off (user request, 2026-10-22); + * - else a bomb next to the player, with Kick: kick it (walking into it); + * - an enemy, any other bomb, or flame still burning: nothing, never a move into it — unless + * flame can't hurt the player (Flame Pass, a Star), then it is walked into like floor. */ function tapTile(p: Vec): void { if (state.status !== 'playing') return @@ -200,13 +213,17 @@ function tapTile(p: Vec): void { afterChange() showTileInfo(p) } else if (selected) { - if (isValidTarget(state, selected, p)) act({ id: selected, target: p }) + // The picked button stands for a group (Move also runs, teleports...): the tile decides which. + const id = resolvePick(state, selected, p) + if (id) act({ id, target: p }) else { selected = null afterChange() } } else if (eq(p, state.player)) pick('wait') - else if (enemyAt(state, p) || bombAt(state, p) || isBurning(state, p)) return + else if (isAvailable(state, 'detonate') && isValidTarget(state, 'detonate', p)) act({ id: 'detonate', target: p }) + else if (isAvailable(state, 'kick') && isValidTarget(state, 'kick', p)) act({ id: 'kick', target: p }) + else if (enemyAt(state, p) || bombAt(state, p) || (isBurning(state, p) && !flameSafe(state))) return else if (destination && eq(destination, p)) stepTowardDestination() else if (isValidTarget(state, 'move', p)) act({ id: 'move', target: p }) else if (cellAt(state, p) === 'floor' && playerPath(state, p)) setDestination(p) @@ -255,9 +272,10 @@ function showMenu(): void { `<p>${where}Seed ${state.seed}. The game saves after every move, so you can close it and come back.</p>` + modeLines(state).map((l) => `<p class="mode">${l}</p>`).join('') + `<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 and Game Icons (CC0). Thank you!</p>`, + `New Platformer Pack, UI Pack, UI Pack: RPG Expansion, Game Icons and Board Game Icons (CC0). Thank you!</p>`, buttons: [ { label: 'Resume', kind: 'primary', icon: playIcon }, + { label: 'Almanac', kind: 'plain', onClick: () => openAlmanac() }, { label: 'New run', kind: 'danger', icon: newRunIcon, onClick: newRunDialog }, ], }) @@ -311,11 +329,13 @@ function startChosenRun(): void { * next, and not into flame this turn. Those would be deaths the player didn't choose. */ function safeStep(p: Vec): boolean { - return !enemyAt(state, p) && !state.enemies.some((e) => e.plan[0] && eq(e.plan[0], p)) && !danger.now.has(idx(state, p)) + // Frozen enemies won't move this turn; flame doesn't matter while it can't hurt. + const intended = state.frozen === 0 && state.enemies.some((e) => e.plan[0] && eq(e.plan[0], p)) + return !enemyAt(state, p) && !intended && (flameSafe(state) || !danger.now.has(idx(state, p))) } /** - * One step of auto-move, if the next step is safe. True if it stepped. With Skates, a path + * One step of auto-move, if the next step is safe. True if it stepped. With Run, a path * going straight on for 2 tiles is taken as one Run, if both tiles are safe. */ function stepTowardDestination(): boolean { @@ -339,27 +359,49 @@ function autoStep(): void { stepTowardDestination() } -function pick(id: ActionId): void { - if (!isAvailable(state, id)) return - inspecting = false +/** + * An action button or key. `pressed`: it, or another action of its button, is already picked, + * so this cancels it. With "What is that?" on, it explains the button instead. + */ +function pick(id: ActionId, pressed = selected === id): void { + if (inspecting) { + inspecting = false + afterChange() + return showInfo(actionInfo(state, id)) + } + if (!canPick(state, id)) return if (id === 'move' && destination && !selected && stepTowardDestination()) return - if (!ACTIONS[id].needsTarget) act({ id }) + // Wait happens at once. Everything else is picked first: a tile to aim at, or for Shield and + // Freeze a "Use" tap in the strip (user request, 2026-10-22: Shield used to go off at once). + if (id === 'wait') act({ id }) else { - selected = selected === id ? null : id + selected = pressed ? null : id afterChange() } } +/** A choice in the explanation strip: a charge bomb instead of a plain one, or "Use" (Shield, Freeze). */ +function choose(id: ActionId): void { + if (!canPick(state, id)) return + if (!ACTIONS[id].needsTarget) return act({ id }) + selected = id + afterChange() +} + /** * A direction key: uses the picked action that way (a bomb or kick next to the player, a throw * or run 2 tiles away), or moves if nothing is picked. */ function direction(d: Dir): void { if (state.status !== 'playing') return - const id: ActionId = selected && ACTIONS[selected].needsTarget ? selected : 'move' - const reach = id === 'throw' || id === 'run' ? 2 : 1 - const p = { x: state.player.x + reach * DIRS[d].x, y: state.player.y + reach * DIRS[d].y } - if (isValidTarget(state, id, p)) act({ id, target: p }) + // Nothing picked: a step, or a kick into a bomb. A picked button reaches 2 tiles too (a run, + // a teleport, a throw) where 1 tile can't be used. + const picked: ActionId = selected && ACTIONS[selected].needsTarget ? selected : 'move' + for (const reach of selected ? [1, 2] : [1]) { + const p = { x: state.player.x + reach * DIRS[d].x, y: state.player.y + reach * DIRS[d].y } + const id = resolvePick(state, picked, p) + if (id) return act({ id, target: p }) + } } const controls = createControls({ @@ -368,6 +410,7 @@ const controls = createControls({ if (state.status === 'dead' || state.status === 'cleared') act({ id: 'continue' }) else if (state.status === 'gameOver' || state.status === 'won') newRunDialog() }, + choose, info: (item) => showInfo(hudInfo(item, state)), inspect, menu: showMenu, @@ -381,7 +424,7 @@ const KEY_DIRS: Record<string, Dir> = { } const ACTION_KEYS: Record<string, ActionId> = { - KeyB: 'bomb', KeyR: 'run', KeyX: 'detonate', KeyK: 'kick', KeyT: 'throw', KeyH: 'shield', KeyF: 'freeze', Equal: 'fuseUp', Minus: 'fuseDown', + KeyB: 'bomb', KeyR: 'run', KeyX: 'detonate', KeyK: 'kick', KeyT: 'throw', KeyH: 'shield', KeyF: 'freeze', KeyG: 'diagonal', KeyP: 'teleport', KeyI: 'pierce', KeyO: 'power', KeyL: 'line', KeyY: 'decoy', } @@ -409,7 +452,11 @@ window.addEventListener('keydown', (e) => { destination = null } afterChange() - } else if (e.code === 'Enter') controls.pressBanner() + } else if (e.code === 'Enter') { + // Confirms Shield or Freeze when picked; otherwise presses the banner's button. + if (selected && !ACTIONS[selected].needsTarget) choose(selected) + else controls.pressBanner() + } }) /** Start timing a long press on `p`, or with null, cancel any press in progress. */ diff --git a/src/render/board.ts b/src/render/board.ts index a84b126..fabb6e3 100644 --- a/src/render/board.ts +++ b/src/render/board.ts @@ -1,11 +1,12 @@ -import { drawCircle, drawPoly, drawRect, drawTile, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine' -import { ACTIONS } from '../sim/actions' +import { drawCircle, drawLine, drawPoly, drawRect, drawTile, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine' +import { ACTIONS, isAvailable } from '../sim/actions' +import { pickTargets, resolvePick } from '../sim/picks' import type { Danger } from '../sim/danger' import { blastTiles } from '../sim/explosions' import { dirBetween } from '../sim/enemies' import { cellAt, cloneState, eq, exitOpen, idx } from '../sim/state' import type { ActionId, Bomb, Enemy, GameState, Vec } from '../sim/types' -import { appearScale, dropSprite, FREEZE_SECONDS, GONE_SECONDS, key, progress, pulseScale, type Effects, type Gone } from './effects' +import { appearScale, dropSprite, frostCrack, frostLevel, GONE_SECONDS, key, progress, pulseScale, type Effects, type Gone } from './effects' import { CHARGE_ICONS, digitSprite, ENEMY_SPRITES, POWER_UP_ICONS, sprite, wallSprite, type BoardSprite } from './sprites' /** @@ -91,6 +92,13 @@ const C = { star: rgb(1, 0.85, 0.2, 0.9), blocked: rgb(0.85, 0.2, 0.2, 0.35), stoneOutline: rgb(53 / 255, 53 / 255, 65 / 255), + // Freeze: the sprite multiplied towards ice blue, with a cold glow added on top. + ice: rgb(0.55, 0.8, 1), + frostGlow: rgb(0.2, 0.35, 0.5, 0), + iceBlock: rgb(0.75, 0.92, 1, 0.35), + iceRim: rgb(1, 1, 1, 0.8), + // A bomb the Detonator can set off now: a pulsing red glow behind it. + armed: rgb(1, 0.3, 0.15, 0.45), } /** The stone frame's outline: 8 of its sprites' 128 pixels. */ @@ -152,12 +160,10 @@ export function drawBoard(view: View): void { for (const i of danger.next) if (!danger.now.has(i)) drawRect(toWorld(s, tileOf(s, i)), vec2(0.94), C.dangerNext) for (const i of danger.now) drawRect(toWorld(s, tileOf(s, i)), vec2(0.94), C.dangerNow) - const hovered = selected && hover && ACTIONS[selected].validTargets(s).some((t) => eq(t, hover)) ? hover : null - if (selected) { - for (const t of ACTIONS[selected].validTargets(s)) { - drawRect(toWorld(s, t), vec2(0.94), hovered && eq(t, hovered) ? C.targetHover : C.target) - } - } + // The picked button's tiles: every action it stands for (sim/picks.ts). + const targets = selected ? pickTargets(s, selected) : [] + const hovered = selected && hover && targets.some((t) => eq(t, hover)) ? hover : null + for (const t of targets) drawRect(toWorld(s, t), vec2(0.94), hovered && eq(t, hovered) ? C.targetHover : C.target) if (view.destination && view.path) { for (const [i, t] of view.path.entries()) { if (i === 0) drawRect(toWorld(s, t), vec2(0.94), view.pathBlocked ? C.blocked : C.target) @@ -169,7 +175,7 @@ export function drawBoard(view: View): void { const after = view.armed ? previewOf(s, 'bomb', view.armed) : selected && hovered - ? previewOf(s, selected, hovered) + ? previewOf(s, resolvePick(s, selected, hovered) ?? selected, hovered) : null const changedBombs = after ? after.bombs.filter((b) => !s.bombs.some((o) => eq(o.pos, b.pos) && o.timer === b.timer)) : [] for (const b of changedBombs) { @@ -194,11 +200,15 @@ export function drawBoard(view: View): void { } } + // With the Detonator, every bomb pulses: tap one and it goes off (user request, 2026-10-22). + const detonatable = isAvailable(s, 'detonate') for (const b of s.bombs) { // The last turn before it goes off, it flashes. const hot = b.timer <= 1 && Math.sin(time * 14) > 0 const k = key.bomb(b.pos) - drawBomb(bombPos(s, fx, b.pos), b.timer, hot ? 'bomb_active' : 'bomb', C.white, b.special, appearScale(fx, k, time) * pulseScale(fx, k, time)) + const pos = bombPos(s, fx, b.pos) + if (detonatable) drawCircle(pos, 0.9 + 0.12 * Math.sin(time * 5), C.armed.scale(1, 0.6 + 0.4 * Math.sin(time * 5))) + drawBomb(pos, b.timer, hot ? 'bomb_active' : 'bomb', C.white, b.special, appearScale(fx, k, time) * pulseScale(fx, k, time)) } for (const b of changedBombs) drawBomb(toWorld(s, b.pos), b.timer, 'bomb', C.ghost, b.special) if (s.decoy) drawDecoy(toWorld(s, s.decoy.pos), C.white, appearScale(fx, key.decoy, time)) @@ -325,15 +335,18 @@ function drawEnemy(s: GameState, e: Enemy, stepped: { from: Vec; at: number } | const pos = k < 1 ? toWorld(s, stepped!.from).lerp(toWorld(s, e.pos), k).add(vec2(0, 0.08 * Math.sin(k * Math.PI))) : toWorld(s, e.pos) const art = ENEMY_SPRITES[e.kind] const moving = e.plan.length > 0 - const frame = !moving ? art.rest : Math.floor(time * 2.5 + e.id) % 2 ? art.a : art.b - const angle = art.spins && moving ? time * 3 : 0 + // Frozen (Freeze): still, blue and iced over, thawing a step each turn. + const frost = frostLevel(fx, s, time) + const still = frost > 0.05 + const frame = !moving ? art.rest : still ? art.a : Math.floor(time * 2.5 + e.id) % 2 ? art.a : art.b + const angle = art.spins && moving && !still ? time * 3 : 0 const heading = moving ? dirBetween(e.pos, e.plan[0]!) : e.dir const mirror = art.facesLeft && heading === 'right' - // Just spawned: pops in. Just frozen: flashes icy blue. + // Just spawned: pops in. const size = 0.8 * appearScale(fx, key.enemy(e.id), time) - const frost = 1 - progress(fx.frozenAt, FREEZE_SECONDS, time) - const tint = rgb(1 - 0.45 * frost, 1 - 0.15 * frost, 1) - drawTile(pos.add(vec2(-0.09, 0)), vec2(size), sprite(frame), tint, angle, mirror) + const at = pos.add(vec2(-0.09 + frostCrack(fx, time), 0)) + drawTile(at, vec2(size), sprite(frame), C.white.lerp(C.ice, frost), angle, mirror, C.frostGlow.scale(frost, 1)) + if (frost > 0) drawIce(at, size, frost, e.id) if (!moving) return const gap = 0.29 @@ -341,11 +354,34 @@ function drawEnemy(s: GameState, e: Enemy, stepped: { from: Vec; at: number } | let from = e.pos for (const [i, to] of e.plan.entries()) { const d = vec2(to.x - from.x, from.y - to.y) // world y is up - drawArrow(top.add(vec2(0, -gap * i)), d, 0.26, C.intent.scale(1, PLAN_ALPHA[i] ?? 0.3)) + // Frozen, its plan waits: the arrows go icy and faint until it thaws. + const color = C.intent.lerp(C.ice, frost).scale(1, (PLAN_ALPHA[i] ?? 0.3) * (1 - 0.5 * frost)) + drawArrow(top.add(vec2(0, -gap * i)), d, 0.26, color) from = to } } +/** + * A block of ice over a frozen enemy: a pale see-through square with a bright rim and a glint, + * and a few sparkles twinkling round it. `frost` (0–1) fades all of it as the enemy thaws. + */ +function drawIce(pos: Vector2, size: number, frost: number, seed: number): void { + const side = size * 1.05 + drawRect(pos, vec2(side), C.iceBlock.scale(1, frost)) + const h = side / 2 + const rim = C.iceRim.scale(1, frost) + const corners = [vec2(-h, -h), vec2(h, -h), vec2(h, h), vec2(-h, h)].map((c) => pos.add(c)) + for (let i = 0; i < 4; i++) drawLine(corners[i]!, corners[(i + 1) % 4]!, 0.03, rim) + // A glint across the top-left corner. + drawLine(pos.add(vec2(-h * 0.7, h * 0.2)), pos.add(vec2(-h * 0.2, h * 0.7)), 0.05, rim) + for (let i = 0; i < 3; i++) { + const a = seed * 1.7 + i * 2.1 + const twinkle = Math.max(0, Math.sin(time * 3 + a * 3)) + const p = pos.add(vec2(Math.cos(a) * h * 0.85, Math.sin(a) * h * 0.85)) + drawTile(p, vec2(0.18 * twinkle * frost + 0.01), sprite('star'), C.white.scale(1, frost), time + a, false, C.white) + } +} + /** * A small arrow centred on `pos`, pointing along the unit vector `d`. One polygon (shaft and * head together), so a see-through arrow has no darker band where two shapes would overlap. diff --git a/src/render/effects.ts b/src/render/effects.ts index 1867123..bb4c610 100644 --- a/src/render/effects.ts +++ b/src/render/effects.ts @@ -1,4 +1,5 @@ import { CHARGE_ICONS, coinSprite, POWER_UP_ICONS, type BoardSprite } from './sprites' +import { FREEZE_TURNS } from '../sim/actions' import type { Drop, Enemy, GameEvent, GameState, Vec } from '../sim/types' /** @@ -27,8 +28,8 @@ const THROW_SECONDS = 0.25 const LINE_STAGGER = 0.05 /** A thing picked up waits this long, for the player to arrive, before it rises away. */ const TAKE_DELAY = 0.12 -/** Frozen enemies flash icy blue for this long. */ -export const FREEZE_SECONDS = 0.6 +/** How long the ice takes to change from one Freeze turn to the next (freezing over, thawing a step). */ +export const FROST_SECONDS = 0.5 /** * Something no longer on the board, still drawn while it goes: an enemy killed, a crate @@ -60,7 +61,8 @@ export interface Effects { /** Bombs on their way to their tile, by `key.bomb` of where they end up. */ slides: Map<string, Slide> gone: Gone[] - frozenAt: number + /** The ice on the enemies changing: from one level to another (0 none, 1 frozen solid), starting at `at`. */ + frost: { from: number; to: number; at: number } | null } export const key = { @@ -74,7 +76,7 @@ export const key = { } export function newEffects(): Effects { - return { appeared: new Map(), pulsed: new Map(), slides: new Map(), gone: [], frozenAt: -Infinity } + return { appeared: new Map(), pulsed: new Map(), slides: new Map(), gone: [], frost: null } } /** A new level, or the same one again: nothing from the old board carries over. */ @@ -83,7 +85,7 @@ export function clearEffects(fx: Effects): void { fx.pulsed.clear() fx.slides.clear() fx.gone = [] - fx.frozenAt = -Infinity + fx.frost = null } export function dropSprite(d: Drop): BoardSprite { @@ -115,9 +117,6 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even case 'bombTicked': if (!placed.has(key.bomb(e.pos))) fx.pulsed.set(key.bomb(e.pos), now) break - case 'fuseChanged': - fx.pulsed.set(key.bomb(e.pos), now) - break case 'blockDestroyed': case 'crateCrumbled': fx.gone.push({ kind: 'crate', pos: e.pos, at: now }) @@ -162,10 +161,11 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even break case 'chargeUsed': if (e.id === 'shield') fx.appeared.set(key.shield, now) - if (e.id === 'freeze') fx.frozenAt = now break } } + // Freezing over, or thawing a step. + if (prev.frozen !== next.frozen) fx.frost = { from: frostOf(prev), to: frostOf(next), at: now } // Enemies that weren't there before (spawned from a burned exit or power-up) pop in. for (const enemy of next.enemies) { if (!prev.enemies.some((o) => o.id === enemy.id)) fx.appeared.set(key.enemy(enemy.id), now) @@ -177,6 +177,25 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even } } +/** How frozen the enemies are: 1 just frozen, then a third less each turn, 0 thawed. */ +function frostOf(s: GameState): number { + return Math.min(1, s.frozen / (FREEZE_TURNS - 1)) +} + +/** The ice on the enemies now, easing between turns. */ +export function frostLevel(fx: Effects, s: GameState, now: number): number { + if (!fx.frost) return frostOf(s) + const k = progress(fx.frost.at, FROST_SECONDS, now) + return fx.frost.from + (fx.frost.to - fx.frost.from) * k +} + +/** Ice cracking as it thaws a step: a short shake, 0 otherwise. */ +export function frostCrack(fx: Effects, now: number): number { + if (!fx.frost || fx.frost.to >= fx.frost.from) return 0 + const t = now - fx.frost.at + return t < 0.25 ? 0.03 * Math.sin(t * 70) * (1 - t / 0.25) : 0 +} + /** Forget animations that have finished. */ export function pruneEffects(fx: Effects, now: number): void { fx.gone = fx.gone.filter((g) => now - g.at < GONE_SECONDS[g.kind]) diff --git a/src/render/sprites.ts b/src/render/sprites.ts index b2e8a1d..8da40fe 100644 --- a/src/render/sprites.ts +++ b/src/render/sprites.ts @@ -4,16 +4,27 @@ import type { ChargeId, EnemyKind, PowerUpKind } from '../sim/types' /** * Art from Kenney's New Platformer Pack 1.1 (CC0), copied into `assets/kenney/` under the pack's * own file names, so a sprite can always be found again in the pack. Every file is 128 × 128: - * tiles and enemies from the pack's `Double` folders, characters from `Default`. + * tiles and enemies from the pack's `Double` folders, characters from `Default`. Also + * `assets/composed/`: a sprite plus a modifier badge, built by `tools/compose-icons.sh`. */ -const URLS = import.meta.glob<string>('../../assets/kenney/*.png', { eager: true, query: '?url', import: 'default' }) +const URLS = import.meta.glob<string>(['../../assets/kenney/*.png', '../../assets/composed/*.png'], { + eager: true, + query: '?url', + import: 'default', +}) export function spriteUrl(name: string): string { - const url = URLS[`../../assets/kenney/${name}.png`] - if (!url) throw new Error(`Missing sprite: assets/kenney/${name}.png`) + const url = URLS[`../../assets/kenney/${name}.png`] ?? URLS[`../../assets/composed/${name}.png`] + if (!url) throw new Error(`Missing sprite: ${name}.png in assets/kenney or assets/composed`) return url } +/** + * One-colour icons in the UI's ink (built by tools/compose-icons.sh from Board Game Icons), for + * the HTML only. Drawn with class "mono", so CSS can turn them white on a dark or green background. + */ +export const MONO_ICONS: ReadonlySet<string> = new Set(['icon_move', 'icon_wait', 'icon_fuse']) + /** The sprites drawn on the canvas. Their order is their LittleJS texture index. */ const BOARD = [ 'terrain_stone_horizontal_middle', @@ -50,13 +61,9 @@ const BOARD = [ 'coin_bronze', 'coin_silver', 'coin_gold', - 'spring_out', - 'sign_right', 'switch_blue', 'chain', - 'torch_on_a', 'ramp', - 'window', 'spikes', 'weight', 'bridge_logs', @@ -66,16 +73,9 @@ const BOARD = [ 'character_green_walk_b', 'character_green_duck', 'character_green_jump', - 'gem_blue', - 'gem_red', - 'gem_yellow', - 'gem_green', 'heart', 'star', 'hud_coin', - 'bush', - 'ladder_top', - 'torch_off', 'lever', 'slime_normal_rest', 'slime_normal_walk_a', @@ -86,6 +86,17 @@ const BOARD = [ 'saw_rest', 'saw_a', 'saw_b', + 'powerup_bomb_plus', + 'powerup_fire_plus', + 'powerup_fuse_plus', + 'powerup_run', + 'powerup_kick', + 'powerup_throw', + 'powerup_teleport', + 'powerup_detonator', + 'powerup_wall_pass', + 'powerup_bomb_pass', + 'powerup_flame_pass', ] as const export type BoardSprite = (typeof BOARD)[number] @@ -128,13 +139,9 @@ export function wallSprite(level: number, width: number, height: number, p: { x: /** The icon for each action charge, on the board (as a drop) and on its button. */ export const CHARGE_ICONS: Record<ChargeId, BoardSprite> = { - kick: 'spring_out', - throw: 'sign_right', shield: 'switch_blue', freeze: 'chain', - fuse: 'torch_on_a', diagonal: 'ramp', - teleport: 'window', pierce: 'spikes', power: 'weight', line: 'bridge_logs', @@ -157,17 +164,26 @@ export function coinSprite(amount: number): BoardSprite { return amount >= 5 ? 'coin_gold' : amount >= 2 ? 'coin_silver' : 'coin_bronze' } -/** Each power-up's sprite (PLAN.md §13): gems for the stats and Skates, the rest by meaning. */ +/** + * Each power-up's sprite (PLAN.md §13). Those that change something you already have are that + * thing with a modifier badge (user request, 2026-10-22): a bomb with +, with expanding arrows + * (blast), with a timer (fuse), with an arrow right (kick) or up-right (throw), with ! (Detonator) + * or a shield (Bomb Pass); the Move figure with fast-forward (Run) or an outbox (Teleport); a + * crate with ✕ (Wall Pass); flame with a shield (Flame Pass). The rest by meaning. + */ export const POWER_UP_ICONS: Record<PowerUpKind, BoardSprite> = { - bombPlus: 'gem_blue', - firePlus: 'gem_red', - fusePlus: 'gem_yellow', - skates: 'gem_green', - wallPass: 'bush', - bombPass: 'ladder_top', - flamePass: 'torch_off', - detonator: 'lever', + bombPlus: 'powerup_bomb_plus', + firePlus: 'powerup_fire_plus', + fusePlus: 'powerup_fuse_plus', + run: 'powerup_run', + wallPass: 'powerup_wall_pass', + bombPass: 'powerup_bomb_pass', + flamePass: 'powerup_flame_pass', + detonator: 'powerup_detonator', star: 'star', coinBag: 'hud_coin', heart: 'heart', + kick: 'powerup_kick', + throw: 'powerup_throw', + teleport: 'powerup_teleport', } diff --git a/src/sim/actions.ts b/src/sim/actions.ts index 2cd8af5..8e67bd8 100644 --- a/src/sim/actions.ts +++ b/src/sim/actions.ts @@ -52,10 +52,13 @@ function walkTo(s: GameState, target: Vec, events: GameEvent[]): void { events.push({ type: 'moved', from, to: s.player }) } -/** The furthest a Fuse +1 takes a timer: one digit. */ -const MAX_TIMER = 9 /** How far Teleport reaches (Manhattan distance). */ export const TELEPORT_RANGE = 3 +/** + * Freeze: the enemy moves skipped — this turn's, and the next 3 (user request, 2026-10-22: one + * skipped move was only a wasted turn). + */ +export const FREEZE_TURNS = 4 /** How many enemy plannings a Decoy lasts. */ export const DECOY_TURNS = 3 @@ -122,7 +125,7 @@ export const ACTIONS: Record<ActionId, ActionDef> = { id: 'kick', label: 'Kick', needsTarget: true, - charge: 'kick', + ability: 'kick', // An adjacent bomb with room to slide at least one tile away from the player. validTargets: (s) => DIR_LIST.map((d) => DIRS[d]).flatMap((d) => { @@ -140,7 +143,7 @@ export const ACTIONS: Record<ActionId, ActionDef> = { id: 'throw', label: 'Throw', needsTarget: true, - charge: 'throw', + ability: 'throw', // Exactly 2 tiles away in a straight line, over anything in between. validTargets: (s) => !hasCapacity(s) @@ -165,25 +168,9 @@ export const ACTIONS: Record<ActionId, ActionDef> = { charge: 'freeze', validTargets: () => [], apply(s) { - s.frozen = true + s.frozen = FREEZE_TURNS }, }, - fuseUp: { - id: 'fuseUp', - label: 'Fuse +1', - needsTarget: true, - charge: 'fuse', - validTargets: (s) => s.bombs.filter((b) => b.timer < MAX_TIMER).map((b) => b.pos), - apply: (s, target, events) => changeFuse(s, target!, 1, events), - }, - fuseDown: { - id: 'fuseDown', - label: 'Fuse −1', - needsTarget: true, - charge: 'fuse', - validTargets: (s) => s.bombs.filter((b) => b.timer > 1).map((b) => b.pos), - apply: (s, target, events) => changeFuse(s, target!, -1, events), - }, diagonal: { id: 'diagonal', label: 'Diagonal', @@ -205,7 +192,7 @@ export const ACTIONS: Record<ActionId, ActionDef> = { id: 'teleport', label: 'Teleport', needsTarget: true, - charge: 'teleport', + ability: 'teleport', validTargets(s) { const tiles: Vec[] = [] for (let dy = -TELEPORT_RANGE; dy <= TELEPORT_RANGE; dy++) { @@ -260,14 +247,8 @@ export const ACTIONS: Record<ActionId, ActionDef> = { }, } -function changeFuse(s: GameState, at: Vec, delta: number, events: GameEvent[]): void { - const bomb = bombAt(s, at)! - bomb.timer += delta - events.push({ type: 'fuseChanged', pos: bomb.pos, timer: bomb.timer }) -} - export const ACTION_ORDER: readonly ActionId[] = [ - 'move', 'bomb', 'wait', 'run', 'detonate', 'kick', 'throw', 'shield', 'freeze', 'fuseUp', 'fuseDown', 'diagonal', 'teleport', + 'move', 'bomb', 'wait', 'run', 'detonate', 'kick', 'throw', 'shield', 'freeze', 'diagonal', 'teleport', 'pierce', 'power', 'line', 'decoy', ] diff --git a/src/sim/enemies.ts b/src/sim/enemies.ts index 715aa0b..7cbd149 100644 --- a/src/sim/enemies.ts +++ b/src/sim/enemies.ts @@ -117,11 +117,11 @@ export function planIntents(s: GameState): void { /** * Phase 2: every enemy takes the first move of its plan. One whose tile was blocked this turn * (a bomb placed there) stays put and does not re-plan until phase 5, which plans it afresh. - * Frozen, they all stay put once and keep their plans. + * Frozen, they all stay put and keep their plans, for as long as the Freeze lasts. */ export function moveEnemies(s: GameState, events: GameEvent[]): void { - if (s.frozen) { - s.frozen = false + if (s.frozen > 0) { + s.frozen-- return } for (const e of s.enemies) { @@ -144,12 +144,9 @@ const DROP_CHANCE: Record<EnemyKind, { coins: number; charge: number }> = { drifter: { coins: 0.2, charge: 0.5 }, } -/** Teleport is rare (PLAN.md §3.3): half as likely as each other charge. */ +/** Every charge is equally likely. */ function pickCharge(rng: Rng): ChargeId { - const weight = (id: ChargeId) => (id === 'teleport' ? 0.5 : 1) - let r = rng.next() * CHARGE_IDS.reduce((sum, id) => sum + weight(id), 0) - for (const id of CHARGE_IDS) if ((r -= weight(id)) < 0) return id - return CHARGE_IDS[CHARGE_IDS.length - 1]! + return CHARGE_IDS[Math.floor(rng.next() * CHARGE_IDS.length)]! } /** What a slain enemy leaves on its tile. Coins are worth 1 (60%), 2 (30%) or 5 (10%). */ diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts index 8d0a224..dbb6bed 100644 --- a/src/sim/levelgen.ts +++ b/src/sim/levelgen.ts @@ -63,21 +63,23 @@ export function lastLevel(size: BoardSize): number { } /** - * How likely each power-up is to be a level's hidden one. Skates aren't in the draw: they are + * How likely each power-up is to be a level's hidden one. Run isn't in the draw: it is * guaranteed on level 1 or 2 instead (which one, from the seed), so every run gets Run early. * To tune by playtesting. */ const POWER_UP_WEIGHTS: [PowerUpKind, number][] = [ ['bombPlus', 20], ['firePlus', 20], ['fusePlus', 6], ['coinBag', 12], ['heart', 4], ['wallPass', 8], ['bombPass', 8], ['flamePass', 5], ['detonator', 9], ['star', 8], + ['kick', 8], ['throw', 8], ['teleport', 3], ] -export function skatesLevel(seed: number): number { +/** Level 1 or 2, from the seed. (The hash key is still 'skates', its old name, so every seed keeps its level.) */ +export function runLevel(seed: number): number { return 1 + (hashSeed(seed, 'skates') & 1) } function pickPowerUp(seed: number, level: number, rng: Rng): PowerUpKind { - if (level === skatesLevel(seed)) return 'skates' + if (level === runLevel(seed)) return 'run' let r = rng.next() * POWER_UP_WEIGHTS.reduce((sum, [, w]) => sum + w, 0) for (const [kind, w] of POWER_UP_WEIGHTS) if ((r -= w) < 0) return kind return POWER_UP_WEIGHTS[0]![0] diff --git a/src/sim/picks.ts b/src/sim/picks.ts new file mode 100644 index 0000000..fd92230 --- /dev/null +++ b/src/sim/picks.ts @@ -0,0 +1,43 @@ +import { ACTIONS, isAvailable, isValidTarget } from './actions' +import { eq } from './state' +import type { ActionId, GameState, Vec } from './types' + +/** + * The action buttons stand for groups of actions (user request, 2026-10-22): Move also kicks, + * runs, teleports and steps diagonally; Bomb also throws, and with the Detonator a tap on one of + * your bombs sets it off. Which one a tap on a tile means + * depends on the tile. The record still holds the actual action (Run, Throw...). + * + * Members are in order of preference: a tile two of them can reach goes to the earlier one, so + * a kick beats walking onto the bomb with Bomb Pass (walking into a bomb kicks it, if it can + * slide), a plain step beats a run, and anything free beats spending a charge (Diagonal last). + */ +const GROUPS: Partial<Record<ActionId, readonly ActionId[]>> = { + move: ['kick', 'move', 'run', 'teleport', 'diagonal'], + bomb: ['bomb', 'throw', 'detonate'], +} + +/** The actions a picked one stands for: its group, or just itself. */ +export function membersOf(picked: ActionId): readonly ActionId[] { + return GROUPS[picked] ?? [picked] +} + +/** The action a tap on `target` means with `picked` picked, or null if it means nothing. */ +export function resolvePick(s: GameState, picked: ActionId, target: Vec): ActionId | null { + return membersOf(picked).find((id) => isAvailable(s, id) && isValidTarget(s, id, target)) ?? null +} + +/** Every tile a tap can use with `picked` picked, each once. */ +export function pickTargets(s: GameState, picked: ActionId): Vec[] { + const tiles: Vec[] = [] + for (const id of membersOf(picked)) { + if (!isAvailable(s, id)) continue + for (const p of ACTIONS[id].validTargets(s)) if (!tiles.some((t) => eq(t, p))) tiles.push(p) + } + return tiles +} + +/** Whether `picked` can be picked right now: some action it stands for is available. */ +export function canPick(s: GameState, picked: ActionId): boolean { + return membersOf(picked).some((id) => isAvailable(s, id)) +} diff --git a/src/sim/record.ts b/src/sim/record.ts index 3ca2d50..5b6029e 100644 --- a/src/sim/record.ts +++ b/src/sim/record.ts @@ -6,7 +6,7 @@ import { DEFAULT_OPTIONS, type Action, type GameState, type RunOptions } from '. * Bumped whenever a rule change would make an old record replay differently. Records from * another version can't be trusted to replay (PLAN.md §11). */ -export const SIM_VERSION = 6 +export const SIM_VERSION = 7 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */ export interface RunRecord { diff --git a/src/sim/state.ts b/src/sim/state.ts index eea7958..fd86ed4 100644 --- a/src/sim/state.ts +++ b/src/sim/state.ts @@ -22,7 +22,7 @@ export interface Carry { } export function noPowers(): Powers { - return { run: false, detonate: false, wallPass: false, bombPass: false, flamePass: false } + return { run: false, kick: false, throw: false, teleport: false, detonate: false, wallPass: false, bombPass: false, flamePass: false } } /** `n` of every action charge. */ @@ -58,13 +58,13 @@ export function createLevel(seed: number, level: number, options: RunOptions, ca bombs: [], status: 'playing', enemies: enemies.map((e, id) => ({ id, kind: e.kind, pos: e.pos, dir: e.dir, plan: [], spawned: false })), nextEnemyId: enemies.length, - drops: [], exit, par, shield: false, frozen: false, decoy: null, burning: [], + drops: [], exit, par, shield: false, frozen: 0, decoy: null, burning: [], powers: { ...carry.powers }, star: carry.star, powerUp, runTurns: carry.runTurns, deaths: carry.deaths, crumbling: [], aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level), } // Developer mode: everything, every level and after every death. if (options.dev) { - s.powers = { run: true, detonate: true, wallPass: true, bombPass: true, flamePass: true } + s.powers = { run: true, kick: true, throw: true, teleport: true, detonate: true, wallPass: true, bombPass: true, flamePass: true } s.stats = { ...DEV_STATS, fuse: Math.max(DEV_STATS.fuse, s.stats.fuse) } s.charges = charges(DEV_CHARGES) } @@ -74,11 +74,12 @@ export function createLevel(seed: number, level: number, options: RunOptions, ca /** * What the player takes on. After a death, the NES rule: Wall Pass, Bomb Pass, Flame Pass, - * Detonator and Star are lost; stats, Skates (Run), coins and charges are kept. (The Keep / Fresh - * choice of PLAN.md §9 will decide about the stats and Skates.) + * Detonator and Star are lost; stats, Run, Kick, Throw, Teleport, coins and charges are + * kept. (The Keep / Fresh choice of PLAN.md §9 will decide about the stats and Run.) */ export function carryOf(s: GameState, died = false): Carry { - const powers = died ? { ...noPowers(), run: s.powers.run } : s.powers + const { run, kick, throw: canThrow, teleport } = s.powers + const powers = died ? { ...noPowers(), run, kick, throw: canThrow, teleport } : s.powers return { lives: s.lives, stats: s.stats, coins: s.coins, charges: s.charges, powers, star: died ? 0 : s.star, runTurns: s.runTurns, deaths: s.deaths, @@ -135,7 +136,15 @@ export function playerCanPass(s: GameState, p: Vec): boolean { return (cell === 'floor' || (cell === 'block' && s.powers.wallPass)) && (!bombAt(s, p) || s.powers.bombPass) } -/** Where the player can step or land now: passable, and not on fire (unless Flame Pass). */ +/** Flame can't hurt the player this turn: Flame Pass, a Star, or God mode. */ +export function flameSafe(s: GameState): boolean { + return s.powers.flamePass || s.star > 0 || s.options.god +} + +/** + * Where the player can step or land now: passable, and not on fire unless fire is safe for them + * (user request, 2026-10-22: with a Star it used to be off limits too). + */ export function playerCanEnter(s: GameState, p: Vec): boolean { - return playerCanPass(s, p) && (!isBurning(s, p) || s.powers.flamePass) + return playerCanPass(s, p) && (!isBurning(s, p) || flameSafe(s)) } diff --git a/src/sim/turn.ts b/src/sim/turn.ts index 2e6b6f0..ec8a83e 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -243,7 +243,7 @@ function applyPowerUp(s: GameState, kind: PowerUpKind): void { case 'bombPlus': s.stats.capacity++; break case 'firePlus': s.stats.fire++; break case 'fusePlus': s.stats.fuse++; break - case 'skates': s.powers.run = true; break + case 'run': s.powers.run = true; break case 'detonator': s.powers.detonate = true; break case 'wallPass': s.powers.wallPass = true; break case 'bombPass': s.powers.bombPass = true; break @@ -251,5 +251,8 @@ function applyPowerUp(s: GameState, kind: PowerUpKind): void { case 'star': s.star = STAR_TURNS; break case 'coinBag': s.coins += COIN_BAG; break case 'heart': s.lives++; break + case 'kick': s.powers.kick = true; break + case 'throw': s.powers.throw = true; break + case 'teleport': s.powers.teleport = true; break } } diff --git a/src/sim/types.ts b/src/sim/types.ts index d105d93..b93d3d8 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -69,18 +69,27 @@ export const DEFAULT_OPTIONS: RunOptions = { size: 'normal', dev: false, god: fa /** The power-ups hidden one per level under a soft block (PLAN.md §5, after the NES eight). */ export type PowerUpKind = - | 'bombPlus' | 'firePlus' | 'fusePlus' | 'skates' | 'wallPass' | 'bombPass' | 'flamePass' | 'detonator' - | 'star' | 'coinBag' | 'heart' + | 'bombPlus' | 'firePlus' | 'fusePlus' | 'run' | 'wallPass' | 'bombPass' | 'flamePass' | 'detonator' + | 'star' | 'coinBag' | 'heart' | 'kick' | 'throw' | 'teleport' export interface PowerUp { pos: Vec kind: PowerUpKind } -/** Abilities from power-ups. Run and Detonate are actions; the passes change where the player can go. */ +/** + * Abilities from power-ups. Run, Detonate, Kick, Throw and Teleport are actions; the passes + * change where the player can go. + */ export interface Powers { - /** Skates: the Run action, 2 tiles in a straight line. Kept on death. */ + /** The Run power-up (called Skates until 2026-10-22): 2 tiles in a straight line. Kept on death. */ run: boolean + /** Kick: walk into a bomb and it slides away. Kept on death (user request, 2026-10-22). */ + kick: boolean + /** Throw: a bomb 2 tiles away in a straight line, over anything. Kept on death. */ + throw: boolean + /** Teleport: move to any free tile up to TELEPORT_RANGE steps away, over anything. Kept on death. */ + teleport: boolean /** Detonator: the Detonate action, set off one of your bombs now. Lost on death. */ detonate: boolean /** Walk through soft blocks. Lost on death. */ @@ -110,16 +119,19 @@ export interface Enemy { spawned: boolean } -/** The consumable action charges dropped by enemies (PLAN.md §3.3). */ -export type ChargeId = - | 'kick' | 'throw' | 'shield' | 'freeze' | 'fuse' | 'diagonal' | 'teleport' | 'pierce' | 'power' | 'line' | 'decoy' +/** + * The consumable action charges dropped by enemies (PLAN.md §3.3). Kick, Throw and Teleport + * were charges until 2026-10-22; they are power-up abilities now. + */ +export type ChargeId = 'shield' | 'freeze' | 'diagonal' | 'pierce' | 'power' | 'line' | 'decoy' -export const CHARGE_IDS: readonly ChargeId[] = [ - 'kick', 'throw', 'shield', 'freeze', 'fuse', 'diagonal', 'teleport', 'pierce', 'power', 'line', 'decoy', -] +export const CHARGE_IDS: readonly ChargeId[] = ['shield', 'freeze', 'diagonal', 'pierce', 'power', 'line', 'decoy'] -/** The actions charges give: one each, except a Fuse charge, which is spent by Fuse +1 or Fuse −1. */ -export type ChargeActionId = Exclude<ChargeId, 'fuse'> | 'fuseUp' | 'fuseDown' +/** + * The actions charges give, one each. (The Fuse ±1 charge was removed on 2026-10-22, at the + * user's request: changing a bomb's number after placing it isn't from the original.) + */ +export type ChargeActionId = ChargeId /** A stand-in for the player that chasing enemies go after instead, for a few turns. */ export interface Decoy { @@ -157,8 +169,8 @@ export interface GameState { charges: Record<ChargeId, number> /** Shield armed: the next hit (flame or enemy) is absorbed. */ shield: boolean - /** Enemies skip their next move (Freeze). */ - frozen: boolean + /** Freeze: how many more enemy moves are skipped (FREEZE_TURNS when used; the turn it is used counts). */ + frozen: number powers: Powers /** Turns played and lives lost over the whole run, for the ending. */ runTurns: number @@ -182,7 +194,7 @@ export interface GameState { } /** The actions the player can pick on the board. */ -export type ActionId = 'move' | 'bomb' | 'wait' | 'run' | 'detonate' | ChargeActionId +export type ActionId = 'move' | 'bomb' | 'wait' | 'run' | 'detonate' | 'kick' | 'throw' | 'teleport' | ChargeActionId export type Action = { id: ActionId; target?: Vec } | { id: 'continue' } @@ -209,7 +221,6 @@ export type GameEvent = | { type: 'chargeUsed'; id: ChargeId } | { type: 'bombKicked'; from: Vec; to: Vec } | { type: 'shieldBroke'; pos: Vec } - | { type: 'fuseChanged'; pos: Vec; timer: number } | { type: 'decoyPlaced'; pos: Vec } | { type: 'decoyGone'; pos: Vec; burned: boolean } | { type: 'powerUpRevealed'; powerUp: PowerUp } diff --git a/src/style.css b/src/style.css index 2227884..20dfdc9 100644 --- a/src/style.css +++ b/src/style.css @@ -44,17 +44,44 @@ img { right: 0; z-index: 2; display: flex; - flex-wrap: wrap; - justify-content: space-between; align-items: center; - gap: 4px 12px; + gap: 12px; padding: calc(env(safe-area-inset-top) + 6px) 16px 6px; background: var(--ink); color: #fff; } -#hud > span { +/* The items wrap onto as many rows as they need; the round buttons stay on the right, stacked + beside the rows on a phone, side by side on a wide screen. */ +#hud-items { + flex: 1; + min-width: 0; display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 4px 12px; +} + +/* On a phone the menu sits above "?" (user request, 2026-10-22); in the HTML "?" comes first, + which is the order wanted side by side on a wide screen. */ +#hud-buttons { + display: flex; + flex-direction: column-reverse; + gap: 4px; +} + +@media (min-width: 601px) { + #hud-buttons { + flex-direction: row; + gap: 6px; + } +} + +#hud-items > span { + display: flex; + /* A long row (many passes held) wraps instead of pushing the buttons off the screen. */ + flex-wrap: wrap; align-items: center; } @@ -65,6 +92,11 @@ img { filter: drop-shadow(0 0 1.5px rgb(255 255 255 / 0.7)); } +/* One-colour ink icons (the fuse timer) turn white on the dark bar. */ +#hud .icon.mono { + filter: brightness(0) invert(1); +} + #hud .icon.small { width: 22px; height: 22px; @@ -128,15 +160,15 @@ img { background: var(--ink); } -#actions button { - /* Three to a row beside the narrow "?", so Move / Bomb / Wait fill the first and any charges wrap below. */ - flex: 1 0 calc((100% - 72px) / 3); +#actions > button { + /* Icons only (names are in the explanation strip and the tooltip). Three to a row, so Move / + Bomb / Wait fill the first and the rest wrap below. */ + flex: 1 0 calc(33.333% - 6px); display: grid; - grid-template-columns: auto auto; - grid-template-rows: auto auto; + grid-template-columns: auto; align-items: center; + justify-items: center; justify-content: center; - column-gap: 6px; min-width: 0; min-height: 56px; margin-bottom: 4px; @@ -151,61 +183,30 @@ img { } #actions .icon { - grid-row: span 2; width: 36px; height: 36px; } -/* "What is that?": pick it, then tap a tile to have it explained. */ -#actions button.inspect { - flex: 0 0 48px; - grid-template-columns: auto; - justify-items: center; - padding: 0; -} - -/* Kenney's Game Icons "question", black; white on the green of a picked button. */ -#actions .inspect .glyph { - width: 26px; - height: 26px; - opacity: 0.85; -} - -#actions .inspect[aria-pressed='true'] .glyph { - filter: invert(1); - opacity: 1; -} - -#actions button[hidden] { +#actions > button[hidden] { display: none; } -/* Action charges: a second, smaller row under Move / Bomb / Wait. */ -#actions button.charge { - /* Quarter width whatever their number, so a single charge isn't a full-width bar. */ - flex: 0 0 calc(25% - 6px); - min-width: 0; +/* Charges and Detonate: a second, smaller row under Move / Bomb / Wait, up to five of them, + each a fifth wide whatever their number, so a single one isn't a full-width bar. */ +#actions > button.charge { + flex: 0 0 calc((100% - 32px) / 5); min-height: 44px; margin-top: 6px; - font-size: 13px; -} - -/* Icon above the name, so names like "Teleport" fit four to a row on a phone. */ -#actions button.charge { - grid-template-columns: auto; - justify-items: center; - row-gap: 0; padding: 2px 2px 4px; - white-space: nowrap; + font-size: 11px; } -#actions button.charge .icon { - grid-row: auto; - width: 26px; - height: 26px; +#actions > button.charge .icon { + width: 28px; + height: 28px; } -#actions button { +#actions > button { position: relative; } @@ -223,39 +224,103 @@ img { font-weight: 800; } -/* On a phone there is no keyboard to hint at, and the buttons get the room. */ +/* On a phone there is no keyboard to hint at. */ @media (max-width: 600px) { - #actions button { - grid-template-rows: auto; - } - - #actions .icon { - grid-row: auto; - } - #actions kbd { display: none; } +} + +/* The strip above the action bar: what the picked action does now, and its choices. It sits + over the board's bottom edge, so showing it doesn't move the board. */ +#explain { + position: absolute; + bottom: 100%; + left: 0; + right: 0; + padding: 8px 16px 10px; + border-radius: 12px 12px 0 0; + background: rgb(53 53 65 / 0.94); + color: #fff; + font-size: 14px; + line-height: 1.35; + animation: fade-in 0.15s ease-out; +} + +#explain[hidden] { + display: none; +} + +#explain .explain-head { + display: flex; + align-items: center; + gap: 8px; + font: 15px 'Kenney Future', ui-rounded, system-ui, sans-serif; +} +#explain .explain-icon { + width: 24px; + height: 24px; } -#actions button:active:not(:disabled) { +#explain p { + margin: 4px 0 0; +} + +#explain .chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +#explain .chip { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px 4px 6px; + border: 2px solid #fff; + border-radius: 16px; + background: none; + color: #fff; + font: inherit; + font-weight: 800; + cursor: pointer; +} + +#explain .chip[aria-pressed='true'] { + border-color: var(--accent); + background: var(--accent); +} + +#explain .chip-icon { + width: 20px; + height: 20px; +} + +#actions > button:active:not(:disabled) { transform: translateY(4px); box-shadow: none; } /* Auto-move: Move has become "Go", one step toward the flag per press. */ -#actions button.auto { +#actions > button.auto { border-color: var(--accent-dark); box-shadow: 0 4px 0 var(--accent-dark); } -#actions button[aria-pressed='true'] { +#actions > button[aria-pressed='true'] { background: var(--accent); color: #fff; } -#actions button:disabled { +/* One-colour ink icons (Move, Wait) turn white on the green of a picked button. */ +#actions > button[aria-pressed='true'] .mono, +#explain .mono { + filter: brightness(0) invert(1); +} + +#actions > button:disabled { opacity: 0.45; } @@ -264,7 +329,7 @@ img { color: var(--muted); } -#actions button[aria-pressed='true'] kbd { +#actions > button[aria-pressed='true'] kbd { color: #e2fff2; } @@ -412,13 +477,44 @@ img { margin-left: 4px; } +/* "What is that?": a round button like the menu's, with Kenney's Game Icons "question"; green + while it is on (the next tap explains). */ +#info-button { + display: grid; + place-content: center; + width: 40px; + height: 40px; + padding: 0 0 3px; + border: 0; + background: url('../assets/kenney-ui/Grey/button_round_depth_flat.png') center / contain no-repeat; + cursor: pointer; +} + +#info-button img { + width: 20px; + height: 20px; + opacity: 0.85; +} + +#info-button[aria-pressed='true'] { + background-image: url('../assets/kenney-ui/Green/button_round_depth_flat.png'); +} + +#info-button[aria-pressed='true'] img { + filter: invert(1); + opacity: 1; +} + +#info-button:disabled { + opacity: 0.45; +} + #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; @@ -488,6 +584,8 @@ img { #dialog-body { max-height: calc(100vh - 260px); overflow-y: auto; + /* Scrolls by touch, wheel or trackpad, without a scroll bar across the parchment. */ + scrollbar-width: none; font-size: 15px; line-height: 1.4; } @@ -505,6 +603,59 @@ img { color: inherit; } +/* The almanac (ui/almanac.ts): tabs, then a picture and the words for each thing. */ +.almanac-tabs { + position: sticky; + top: 0; + display: flex; + justify-content: center; + gap: 6px; + padding-bottom: 8px; + background: #ece3cc; +} + +.almanac-tabs button { + padding: 4px 8px; + white-space: nowrap; + border: 2px solid var(--ink); + border-radius: 14px; + background: none; + color: var(--ink); + font: 13px 'Kenney Future', ui-rounded, system-ui, sans-serif; + cursor: pointer; +} + +.almanac-tabs button[aria-pressed='true'] { + border-color: var(--accent-dark); + background: var(--accent); + color: #fff; +} + +#dialog-body .almanac-intro { + font-size: 13px; + color: var(--muted); +} + +.almanac-entry { + display: grid; + grid-template-columns: 44px 1fr; + gap: 10px; + align-items: start; + padding: 8px 0; + border-top: 1px solid rgb(53 53 65 / 0.15); + text-align: left; +} + +.almanac-entry img { + width: 44px; + height: 44px; +} + +#dialog-body .almanac-entry p { + margin: 2px 0 0; + font-size: 14px; +} + /* Keyboard focus in the dialog: a yellow ring, like the count badges. */ #dialog button:focus-visible { outline: 3px solid #ffc83d; @@ -599,20 +750,7 @@ img { text-align: left; } -/* Lots of charges / abilities (Developer mode): five to a row, shorter, smaller icons. */ -#actions.many button.charge { - flex-basis: calc(20% - 7px); - min-height: 34px; - margin-top: 4px; - font-size: 11px; -} -#actions.many button.charge .icon { - width: 20px; - height: 20px; -} - -#actions.many .count { - top: -8px; - font-size: 10px; +#dialog-body::-webkit-scrollbar { + display: none; } diff --git a/src/ui/almanac.ts b/src/ui/almanac.ts new file mode 100644 index 0000000..ca1ab6c --- /dev/null +++ b/src/ui/almanac.ts @@ -0,0 +1,71 @@ +import { enemyKinds } from '../sim/levelgen' +import { CHARGE_IDS, type EnemyKind, type PowerUpKind } from '../sim/types' +import { CHARGE_ICONS, ENEMY_SPRITES, POWER_UP_ICONS, spriteUrl } from '../render/sprites' +import { openDialog } from './dialog' +import { CHARGE_TEXT, ENEMY_TEXT, POWER_UP_TEXT } from './info' + +/** + * The almanac (user request, 2026-10-22): every power-up, action charge and enemy type, with its + * picture and what it does, opened from the menu. Its tabs swap the list in place. + */ +export type AlmanacTab = 'powerUps' | 'charges' | 'enemies' + +const TABS: [AlmanacTab, string][] = [ + ['powerUps', 'Power-ups'], + ['charges', 'Charges'], + ['enemies', 'Enemies'], +] + +/** The power-ups in the order they make sense to read: stats, abilities, passes, the rest. */ +const POWER_UPS: PowerUpKind[] = [ + 'bombPlus', 'firePlus', 'fusePlus', 'run', 'kick', 'throw', 'teleport', 'detonator', + 'wallPass', 'bombPass', 'flamePass', 'star', 'heart', 'coinBag', +] + +const ENEMIES: EnemyKind[] = ['blob', 'seeker', 'drifter'] + +/** The first level an enemy type can appear on. */ +function firstLevel(kind: EnemyKind): number { + let level = 1 + while (!enemyKinds(level).includes(kind)) level++ + return level +} + +function entry(sprite: string, name: string, lines: string[]): string { + return ( + `<div class="almanac-entry"><img src="${spriteUrl(sprite)}" alt="" draggable="false">` + + `<div><strong>${name}</strong>${lines.map((l) => `<p>${l}</p>`).join('')}</div></div>` + ) +} + +function body(tab: AlmanacTab): string { + const tabs = TABS.map(([id, label]) => `<button type="button" data-tab="${id}" aria-pressed="${id === tab}">${label}</button>`).join('') + let list: string + if (tab === 'powerUps') { + list = + '<p class="almanac-intro">One is hidden under a crate on every level. Burn the crate, then walk onto it.</p>' + + POWER_UPS.map((k) => entry(POWER_UP_ICONS[k], POWER_UP_TEXT[k].name, [POWER_UP_TEXT[k].text])).join('') + } else if (tab === 'charges') { + list = + '<p class="almanac-intro">Enemies sometimes drop these. Each is used once and takes your turn.</p>' + + CHARGE_IDS.map((id) => entry(CHARGE_ICONS[id], CHARGE_TEXT[id].name, [CHARGE_TEXT[id].text])).join('') + } else { + list = + '<p class="almanac-intro">Every enemy shows its next 3 moves as arrows. Flame kills them; touching one costs you a life.</p>' + + ENEMIES.map((k) => entry(ENEMY_SPRITES[k].a, ENEMY_TEXT[k].name, [ENEMY_TEXT[k].moves, `From level ${firstLevel(k)}.`])).join('') + } + return `<div class="almanac-tabs" role="group" aria-label="Almanac sections">${tabs}</div>${list}` +} + +export function openAlmanac(tab: AlmanacTab = 'powerUps'): void { + openDialog({ title: 'Almanac', body: body(tab), buttons: [{ label: 'Close', kind: 'plain' }] }) +} + +// A tab swaps the list without closing the dialog. +const dialogBody = document.getElementById('dialog-body')! +dialogBody.addEventListener('click', (e) => { + const tab = (e.target as Element).closest<HTMLElement>('[data-tab]') + if (!tab) return + dialogBody.innerHTML = body(tab.dataset.tab as AlmanacTab) + dialogBody.scrollTop = 0 +}) diff --git a/src/ui/controls.ts b/src/ui/controls.ts index c25fd09..c1e26b0 100644 --- a/src/ui/controls.ts +++ b/src/ui/controls.ts @@ -1,43 +1,74 @@ -import { ACTIONS, ACTION_ORDER, chargeOf, isAvailable } from '../sim/actions' +import { ACTIONS } from '../sim/actions' +import { canPick } from '../sim/picks' 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' +import type { ActionId, ChargeId, GameState, PowerUpKind, Powers } from '../sim/types' +import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites' +import { actionInfo, type HudItem } from './info' import questionIcon from '../../assets/kenney-icons/question.png' /** The HTML around the board: HUD, action bar, and the banner shown between attempts and levels. */ export interface Handlers { - /** An action button was pressed. */ - pick(id: ActionId): void + /** + * An action button was pressed: `id` is the first of its actions that can be picked now, and + * `pressed` says whether one of them is already picked (so the press cancels it). + */ + pick(id: ActionId, pressed: boolean): void + /** One of the choices in the explanation strip: a Pierce bomb instead of a plain one, or "Use" for Shield / Freeze. */ + choose(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 - /** The "What is that?" button: the next tap on the board explains the tile. */ + /** The "What is that?" button: the next tap on the board (or an action button) explains it. */ inspect(): void menu(): void } -export const KEYS: Record<ActionId, string> = { - move: 'WASD', bomb: 'B', wait: 'Space', run: 'R', detonate: 'X', kick: 'K', throw: 'T', shield: 'H', freeze: 'F', fuseUp: '=', fuseDown: '−', - diagonal: 'G', teleport: 'P', pierce: 'I', power: 'O', line: 'L', decoy: 'Y', -} -const ICONS: Record<ActionId, string> = { - move: 'character_green_walk_a', bomb: 'bomb', wait: 'character_green_duck', ...CHARGE_ICONS, - run: POWER_UP_ICONS.skates, detonate: POWER_UP_ICONS.detonator, - fuseUp: CHARGE_ICONS.fuse, fuseDown: CHARGE_ICONS.fuse, +/** + * The action bar: as few buttons as possible, icons only (user request, 2026-10-22). Each stands + * for a group of actions: Move also runs, teleports, kicks and steps diagonally, and Bomb also + * throws and detonates (sim/picks.ts); Bomb's charge bombs are choices in the strip that explains + * the picked action, and Shield and Freeze wait there for a "Use" tap. The rest only appear + * while you have them. + */ +interface ButtonDef { + id: ActionId + label: string + icon: string + key: string + /** The actions it stands for; the first one that can be picked is picked. */ + group: readonly ActionId[] + /** Shown only while the player holds this charge (with a count) or has this ability. */ + charge?: ChargeId + ability?: keyof Powers } +const BUTTONS: ButtonDef[] = [ + { id: 'move', label: 'Move', icon: 'icon_move', key: 'WASD', group: ['move', 'run', 'teleport', 'kick', 'diagonal'] }, + { id: 'bomb', label: 'Bomb', icon: 'bomb', key: 'B', group: ['bomb', 'throw', 'detonate', 'pierce', 'power', 'line'] }, + { id: 'wait', label: 'Wait', icon: 'icon_wait', key: 'Space', group: ['wait'] }, + { id: 'shield', label: 'Shield', icon: CHARGE_ICONS.shield, key: 'H', group: ['shield'], charge: 'shield' }, + { id: 'freeze', label: 'Freeze', icon: CHARGE_ICONS.freeze, key: 'F', group: ['freeze'], charge: 'freeze' }, + { id: 'decoy', label: 'Decoy', icon: CHARGE_ICONS.decoy, key: 'Y', group: ['decoy'], charge: 'decoy' }, +] + +/** Bomb's choices in the strip: a charge bomb instead of a plain one. */ +const BOMB_CHOICES: readonly ActionId[] = ['bomb', 'pierce', 'power', 'line'] + /** The passes shown in the HUD, with their power-up and name. */ const PASSES: [keyof Powers, PowerUpKind, string][] = [ - ['run', 'skates', 'Skates'], + ['run', 'run', 'Run'], + ['kick', 'kick', 'Kick'], + ['throw', 'throw', 'Throw'], + ['teleport', 'teleport', 'Teleport'], ['detonate', 'detonator', 'Detonator'], ['wallPass', 'wallPass', 'Wall Pass'], ['bombPass', 'bombPass', 'Bomb Pass'], ['flamePass', 'flamePass', 'Flame Pass'], ] -/** What a death takes away (the NES rule; Skates are kept). */ -const LOST_ON_DEATH = PASSES.filter(([p]) => p !== 'run') +/** What a death takes away (the NES rule; Run is kept, and so are Kick, Throw and Teleport). */ +const KEPT_ON_DEATH: readonly (keyof Powers)[] = ['run', 'kick', 'throw', 'teleport'] +const LOST_ON_DEATH = PASSES.filter(([p]) => !KEPT_ON_DEATH.includes(p)) /** A top-bar item: a button that opens its info (`data-info`, see ui/info.ts). */ function item(info: HudItem, html: string): string { @@ -68,7 +99,8 @@ function show(slot: HTMLElement, html: string): void { } function icon(name: string, className = 'icon'): string { - return `<img class="${className}" src="${spriteUrl(name)}" alt="" draggable="false">` + const mono = MONO_ICONS.has(name) ? ' mono' : '' + return `<img class="${className}${mono}" src="${spriteUrl(name)}" alt="" draggable="false">` } /** A number in the pack's HUD digits. The images are decoration; `label` is what screen readers get. */ @@ -78,28 +110,68 @@ function number(n: number, label: string): string { export function createControls(handlers: Handlers) { const bar = document.getElementById('actions')! - const buttons = new Map<ActionId, HTMLButtonElement>() - for (const id of ACTION_ORDER) { + const buttons = new Map<ButtonDef, HTMLButtonElement>() + for (const def of BUTTONS) { const b = document.createElement('button') b.type = 'button' - b.dataset.action = id - // Charges and power-up abilities share the smaller second row. - if (chargeOf(id) || ACTIONS[id].ability) b.classList.add('charge') - b.innerHTML = `${icon(ICONS[id])}<span class="label">${ACTIONS[id].label}</span><kbd>${KEYS[id]}</kbd>` - if (chargeOf(id)) b.insertAdjacentHTML('beforeend', '<span class="count"></span>') - b.addEventListener('click', () => handlers.pick(id)) + b.dataset.action = def.id + b.title = def.label + b.setAttribute('aria-label', def.label) + // Charges and abilities share the smaller second row, and only show while you have them. + if (def.charge || def.ability) b.classList.add('charge') + b.innerHTML = `${icon(def.icon)}<kbd>${def.key}</kbd>` + if (def.charge) b.insertAdjacentHTML('beforeend', '<span class="count"></span>') + b.addEventListener('click', () => { + const id = def.group.find((a) => canPick(current!, a)) ?? def.id + handlers.pick(id, !!currentSelected && def.group.includes(currentSelected)) + }) bar.append(b) - buttons.set(id, b) + buttons.set(def, b) + } + + // The strip above the bar: what the picked action does now, and its choices. + const strip = document.createElement('div') + strip.id = 'explain' + strip.hidden = true + strip.setAttribute('aria-live', 'polite') + bar.prepend(strip) + strip.addEventListener('click', (e) => { + const chip = (e.target as Element).closest<HTMLElement>('[data-choice]') + if (chip) handlers.choose(chip.dataset.choice as ActionId) + }) + let current: GameState | null = null + let currentSelected: ActionId | null = null + + function showStrip(s: GameState, selected: ActionId | null): void { + const info = selected ? actionInfo(s, selected) : null + strip.hidden = !info + if (!info) return + const chip = (id: ActionId, label: string, sprite: string, on: boolean) => + `<button type="button" class="chip" data-choice="${id}" aria-pressed="${on}">${icon(sprite, 'chip-icon')}${label}</button>` + let chips: string[] = [] + if (selected && !ACTIONS[selected].needsTarget) { + // Shield, Freeze: nothing to aim at, so a tap here confirms (user request, 2026-10-22). + chips = [chip(selected, `Use ${ACTIONS[selected].label}`, CHARGE_ICONS[selected as ChargeId], true)] + } else if (selected && (BOMB_CHOICES.includes(selected) || selected === 'throw' || selected === 'detonate')) { + const bombs = BOMB_CHOICES.filter((id) => canPick(s, id)) + if (bombs.length > 1) { + chips = bombs.map((id) => { + const label = id === 'bomb' ? 'Bomb' : `${ACTIONS[id].label} ${s.options.dev ? '∞' : `×${s.charges[id as ChargeId]}`}` + const on = id === selected || (id === 'bomb' && (selected === 'throw' || selected === 'detonate')) + return chip(id, label, id === 'bomb' ? 'bomb' : CHARGE_ICONS[id as ChargeId], on) + }) + } + } + strip.innerHTML = + `<div class="explain-head">${icon(info.icon, 'explain-icon')}<strong>${info.title}</strong></div>` + + info.body.map((t) => `<p>${t}</p>`).join('') + + (chips.length ? `<div class="chips">${chips.join('')}</div>` : '') } - // "What is that?" sits beside Wait: picked like an action, then a tap on the board explains the tile. - const inspectButton = document.createElement('button') - inspectButton.type = 'button' - inspectButton.className = 'inspect' - inspectButton.setAttribute('aria-label', 'What is that?') - inspectButton.innerHTML = `<img class="glyph" src="${questionIcon}" alt="" draggable="false"><kbd>?</kbd>` + // "What is that?": a round button next to the menu, top right. + const inspectButton = document.getElementById('info-button') as HTMLButtonElement + inspectButton.innerHTML = `<img src="${questionIcon}" alt="" draggable="false">` inspectButton.addEventListener('click', () => handlers.inspect()) - buttons.get('wait')!.after(inspectButton) const banner = document.getElementById('banner')! const bannerText = document.getElementById('banner-text')! @@ -129,28 +201,28 @@ export function createControls(handlers: Handlers) { update(s: GameState, selected: ActionId | null, autoMove: boolean, inspecting: boolean): void { inspectButton.setAttribute('aria-pressed', String(inspecting)) inspectButton.disabled = s.status !== 'playing' - for (const [id, b] of buttons) { - b.disabled = !isAvailable(s, id) - b.setAttribute('aria-pressed', String(selected === id)) - if (id === 'move') { - b.querySelector('.label')!.textContent = autoMove ? 'Go' : ACTIONS.move.label - b.querySelector('.icon')!.setAttribute('src', spriteUrl(autoMove ? 'flag_green_a' : ICONS.move)) + current = s + currentSelected = selected + for (const [def, b] of buttons) { + b.disabled = !def.group.some((id) => canPick(s, id)) + b.setAttribute('aria-pressed', String(!!selected && def.group.includes(selected))) + if (def.id === 'move') { + // Auto-move: Move has become Go, one step toward the flag per press. + b.setAttribute('aria-label', autoMove ? 'Go' : def.label) + b.title = autoMove ? 'Go' : def.label + const img = b.querySelector('.icon')! + img.setAttribute('src', spriteUrl(autoMove ? 'flag_green_a' : def.icon)) + img.classList.toggle('mono', !autoMove) b.classList.toggle('auto', autoMove) } - // Abilities only get a button once the player has them. - const ability = ACTIONS[id].ability - if (ability) b.hidden = !s.powers[ability] - // Charges only get a button while the player holds some; a badge shows how many. - const charge = chargeOf(id) - if (charge) { - b.hidden = s.charges[charge] === 0 + if (def.ability) b.hidden = !s.powers[def.ability] + if (def.charge) { + b.hidden = s.charges[def.charge] === 0 // Developer mode never spends them. - b.querySelector('.count')!.textContent = s.options.dev ? '∞' : `×${s.charges[charge]}` + b.querySelector('.count')!.textContent = s.options.dev ? '∞' : `×${s.charges[def.charge]}` } } - // Many second-row buttons (Developer mode has 13): pack them tighter so the board keeps its room. - const extra = [...buttons.values()].filter((b) => b.classList.contains('charge') && !b.hidden).length - bar.classList.toggle('many', extra > 8) + showStrip(s, s.status === 'playing' ? selected : null) // Lives lost from the starting three show as empty hearts; extra lives add full ones. const hearts = Array.from({ length: Math.max(s.lives, START_LIVES) }, (_, i) => @@ -182,7 +254,7 @@ export function createControls(handlers: Handlers) { hud.stats, 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 ')) + + item('fuse', icon('icon_fuse') + number(s.stats.fuse, 'Fuse ')) + passes + star, ) diff --git a/src/ui/dialog.ts b/src/ui/dialog.ts index 12b2ce1..f37f2af 100644 --- a/src/ui/dialog.ts +++ b/src/ui/dialog.ts @@ -61,6 +61,9 @@ document.getElementById('dialog-close')!.addEventListener('click', () => closeDi dialog.addEventListener('click', (e) => { if (e.target === dialog) closeDialog() }) +// LittleJS cancels every wheel event on the page (so the game never scrolls it), which also +// stopped a long dialog, like the almanac, scrolling with a mouse wheel or trackpad. Kept here. +dialog.addEventListener('wheel', (e) => e.stopPropagation(), { passive: true }) // Esc closes it too, animated like the rest. dialog.addEventListener('cancel', (e) => { e.preventDefault() diff --git a/src/ui/info.ts b/src/ui/info.ts index ca6b3e2..4865294 100644 --- a/src/ui/info.ts +++ b/src/ui/info.ts @@ -1,8 +1,8 @@ -import { DECOY_TURNS, TELEPORT_RANGE } from '../sim/actions' +import { DECOY_TURNS, FREEZE_TURNS, TELEPORT_RANGE } from '../sim/actions' import { lastLevel } 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 type { ActionId, ChargeId, Dir, EnemyKind, GameState, Powers, PowerUpKind, Vec } from '../sim/types' import { dirBetween } from '../sim/enemies' import { CHARGE_ICONS, coinSprite, ENEMY_SPRITES, isOuterWall, POWER_UP_ICONS, spriteUrl, wallSprite } from '../render/sprites' @@ -28,27 +28,26 @@ 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.' }, + run: { name: 'Run', text: 'Move can also run: 2 tiles in a straight line in one turn. Both tiles must be empty. Kept when you die.' }, + detonator: { name: 'Detonator', text: 'Your bombs pulse: tap one 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.' }, + kick: { name: 'Kick', text: 'Walk into a bomb next to you and it slides away until something stops it, keeping its number. Kept when you die.' }, + throw: { name: 'Throw', text: 'Bomb can also put a bomb 2 tiles away in a straight line, over anything in between. Kept when you die.' }, + teleport: { name: 'Teleport', text: `Move can also take you to any empty tile up to ${TELEPORT_RANGE} steps away, over anything. Kept when you die.` }, } 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.' }, + freeze: { name: 'Freeze', text: `Every enemy freezes in place: it skips its move this turn and the next ${FREEZE_TURNS - 1}, thawing as it goes.` }, + diagonal: { name: 'Diagonal', text: 'Move can step one tile diagonally, even between two blocked tiles.' }, + pierce: { name: 'Pierce', text: 'A choice under Bomb: a bomb whose flame goes through crates, burning every one in reach.' }, + power: { name: 'Power', text: 'A choice under Bomb: a bomb whose flame reaches all the way to the walls (crates still stop it).' }, + line: { name: 'Line', text: 'A choice under Bomb: 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.` }, } @@ -68,7 +67,8 @@ export function modeLines(s: GameState): string[] { } const PASS_KIND: Record<keyof Powers, PowerUpKind> = { - run: 'skates', detonate: 'detonator', wallPass: 'wallPass', bombPass: 'bombPass', flamePass: 'flamePass', + run: 'run', kick: 'kick', throw: 'throw', teleport: 'teleport', detonate: 'detonator', wallPass: 'wallPass', + bombPass: 'bombPass', flamePass: 'flamePass', } export function hudInfo(item: HudItem, s: GameState): Info { @@ -112,19 +112,19 @@ export function hudInfo(item: HudItem, s: GameState): Info { 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.'], + body: ['How many of your bombs can be on the board at once. The Bomb +1 power-up 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.'], + body: ['How far your blasts reach in each of the four directions. The Fire +1 power-up adds one.'], } case 'fuse': return { - icon: 'torch_on_a', + icon: 'icon_fuse', 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.`], + body: [`The number your new bombs start at: they explode after ${s.stats.fuse} more of your actions. The Fuse +1 power-up adds one.`], } case 'star': return { icon: 'star', title: `Star: ${s.star} turns left`, body: ['Nothing can hurt you until it runs out.'] } @@ -219,6 +219,69 @@ export function tileInfo(s: GameState, p: Vec): Info | null { return null } +/** Shown when a power-up is picked up (user request, 2026-10-22): what it does, from now on. */ +export function powerUpInfo(kind: PowerUpKind): Info { + const t = POWER_UP_TEXT[kind] + return { icon: POWER_UP_ICONS[kind], title: t.name, body: ['You picked it up!', t.text] } +} + +/** `n` turns, in words. */ +function turns(n: number): string { + return `${n} ${n === 1 ? 'turn' : 'turns'}` +} + +/** "×2" after a charge's name: how many are left (unlimited in Developer mode). */ +function held(s: GameState, id: ChargeId): string { + return s.options.dev ? ' (unlimited)' : ` ×${s.charges[id]}` +} + +/** + * What an action button does, for the strip above the action bar while it is picked (and for + * "What is that?" on a button). It grows as the player gains upgrades, so picking an action + * teaches what it can do now (user request, 2026-10-22). `picked` may be any action of the + * button's group (the keyboard picks Run or Throw directly). + */ +export function actionInfo(s: GameState, picked: ActionId): Info { + switch (picked) { + case 'move': + case 'run': + case 'teleport': + case 'kick': + case 'diagonal': { + const body = ['Tap a highlighted tile: a step to a tile next to you.'] + if (s.powers.run) body.push('Run: 2 tiles in a straight line, if both are empty.') + if (s.powers.teleport) body.push(`Teleport: any empty tile up to ${TELEPORT_RANGE} steps away, over anything.`) + if (s.powers.kick) body.push('Kick: walk into a bomb next to you and it slides away until something stops it.') + if (s.charges.diagonal > 0) body.push(`Diagonal${held(s, 'diagonal')}: a diagonal step, even between two blocked tiles. Uses one.`) + return { icon: 'icon_move', title: 'Move', body } + } + case 'bomb': + case 'throw': + case 'detonate': { + const body = [ + `Tap a highlighted tile next to you. The bomb goes off after ${turns(s.stats.fuse)} of yours; its blast reaches ${s.stats.fire} ${s.stats.fire === 1 ? 'tile' : 'tiles'} each way. Up to ${s.stats.capacity} on the board at once.`, + ] + if (s.powers.throw) body.push('Throw: or 2 tiles away in a straight line, over anything in between.') + if (s.powers.detonate) body.push('Detonator: tap one of your bombs (they pulse) and it goes off this turn. No need to pick Bomb first.') + return { icon: 'bomb', title: 'Bomb', body } + } + case 'pierce': + case 'power': + case 'line': { + const t = CHARGE_TEXT[picked] + return { icon: CHARGE_ICONS[picked], title: `${t.name} bomb${held(s, picked)}`, body: [`${t.text.replace('A choice under Bomb: ', '')} Uses one.`] } + } + case 'wait': + return { icon: 'icon_wait', title: 'Wait', body: ['Do nothing; the world takes its turn. Tapping yourself does the same.'] } + case 'shield': + case 'freeze': + case 'decoy': { + const t = CHARGE_TEXT[picked] + return { icon: CHARGE_ICONS[picked], title: `${t.name}${held(s, picked)}`, body: [t.text] } + } + } +} + export function infoIconUrl(info: Info): string { return spriteUrl(info.icon) } diff --git a/test/charges.test.ts b/test/charges.test.ts index 25489e7..43da85d 100644 --- a/test/charges.test.ts +++ b/test/charges.test.ts @@ -1,37 +1,10 @@ import { describe, expect, it } from 'vitest' -import { ACTIONS, DECOY_TURNS, isAvailable, TELEPORT_RANGE } from '../src/sim/actions' +import { ACTIONS, DECOY_TURNS, TELEPORT_RANGE } from '../src/sim/actions' import { stepToward } from '../src/sim/enemies' import { blastTiles } from '../src/sim/explosions' import { step } from '../src/sim/turn' import { board, v } from './helpers' -describe('Fuse +1 / Fuse −1', () => { - it('share one Fuse charge and change any bomb by one', () => { - const s = board(` - ####### - #P..3.# - #######`) - s.charges.fuse = 1 - expect(isAvailable(s, 'fuseUp') && isAvailable(s, 'fuseDown')).toBe(true) - const up = step(s, { id: 'fuseUp', target: v(4, 1) }) - // 3 + 1, then the turn's tick takes 1 off again. - expect(up.events).toContainEqual({ type: 'fuseChanged', pos: v(4, 1), timer: 4 }) - expect(up.state.bombs[0]!.timer).toBe(3) - expect(up.state.charges.fuse).toBe(0) - expect(isAvailable(up.state, 'fuseDown')).toBe(false) - }) - - it('−1 cannot take a bomb below 1', () => { - const s = board(` - ###### - #P.1.# - ######`) - s.charges.fuse = 1 - expect(ACTIONS.fuseDown.validTargets(s)).toEqual([]) - expect(ACTIONS.fuseUp.validTargets(s)).toEqual([v(3, 1)]) - }) -}) - describe('Diagonal', () => { it('squeezes between two blocked tiles', () => { const s = board(` @@ -51,7 +24,7 @@ describe('Teleport', () => { ######### #P#+...$# #########`) - s.charges.teleport = 1 + s.powers.teleport = true expect(ACTIONS.teleport.validTargets(s)).toEqual([v(4, 1)]) const { state } = step(s, { id: 'teleport', target: v(4, 1) }) expect(state.player).toEqual(v(4, 1)) @@ -62,7 +35,7 @@ describe('Teleport', () => { ###### #P#$.# ######`) - s.charges.teleport = 1 + s.powers.teleport = true expect(step(s, { id: 'teleport', target: v(3, 1) }).state.coins).toBe(1) }) }) diff --git a/test/enemies.test.ts b/test/enemies.test.ts index cd6d4e6..f0a66dc 100644 --- a/test/enemies.test.ts +++ b/test/enemies.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { ACTIONS, isAvailable } from '../src/sim/actions' +import { ACTIONS, FREEZE_TURNS, isAvailable } from '../src/sim/actions' import { PLAN_AHEAD, planIntents, stepToward } from '../src/sim/enemies' import { enemyCount, enemyKinds, generateLevel, SAFE_DISTANCE, START } from '../src/sim/levelgen' import { newRun, PAR_BONUS } from '../src/sim/state' @@ -262,8 +262,8 @@ describe('drops', () => { ##### #P..# #####`) - s.drops.push({ pos: v(2, 1), kind: 'kick' }) - expect(step(s, { id: 'move', target: v(2, 1) }).state.charges.kick).toBe(1) + s.drops.push({ pos: v(2, 1), kind: 'shield' }) + expect(step(s, { id: 'move', target: v(2, 1) }).state.charges.shield).toBe(1) }) it('flame burns drops lying in the open, but not the ones its own kills leave', () => { @@ -376,13 +376,13 @@ describe('the exit', () => { #### #PE# ####`, { fire: 3 }), coins: 4, lives: 2 } - s.charges.kick = 2 + s.charges.shield = 2 const cleared = step(s, { id: 'move', target: v(2, 1) }).state const { state, events } = step(cleared, { id: 'continue' }) expect(events).toEqual([{ type: 'levelStarted', level: 2 }]) expect(state).toMatchObject({ level: 2, turn: 0, lives: 2, coins: 4 + PAR_BONUS, status: 'playing' }) expect(state.stats.fire).toBe(3) - expect(state.charges.kick).toBe(2) + expect(state.charges.shield).toBe(2) expect(state.enemies.length).toBe(enemyCount(2)) }) @@ -398,17 +398,29 @@ describe('the exit', () => { }) }) -describe('action charges', () => { - it('are only offered while held, and using one spends it', () => { +describe('action charges and abilities', () => { + it('a charge is only offered while held, and using one spends it', () => { + const s = board(` + ##### + #P..# + #####`) + expect(isAvailable(s, 'shield')).toBe(false) + s.charges.shield = 1 + expect(isAvailable(s, 'shield')).toBe(true) + const { state } = step(s, { id: 'shield' }) + expect(state.charges.shield).toBe(0) + }) + + it('kick is an ability: only offered once gained, and never used up', () => { const s = board(` ###### #P3..# ######`) expect(isAvailable(s, 'kick')).toBe(false) - s.charges.kick = 1 + s.powers.kick = true expect(isAvailable(s, 'kick')).toBe(true) const { state } = step(s, { id: 'kick', target: v(2, 1) }) - expect(state.charges.kick).toBe(0) + expect(state.powers.kick).toBe(true) }) it('kick: the bomb slides until the next tile cannot hold it, keeping its timer', () => { @@ -416,7 +428,7 @@ describe('action charges', () => { ####### #P3..B# #######`) - s.charges.kick = 1 + s.powers.kick = true const { state, events } = step(s, { id: 'kick', target: v(2, 1) }) expect(events).toContainEqual({ type: 'bombKicked', from: v(2, 1), to: v(4, 1) }) expect(state.bombs[0]).toMatchObject({ pos: v(4, 1), timer: 2 }) @@ -427,7 +439,7 @@ describe('action charges', () => { ##### #P3+# #####`) - s.charges.kick = 1 + s.powers.kick = true expect(ACTIONS.kick.validTargets(s)).toEqual([]) }) @@ -438,7 +450,7 @@ describe('action charges', () => { #.#### #.#### ######`) - s.charges.throw = 1 + s.powers.throw = true expect(ACTIONS.throw.validTargets(s)).toEqual([v(1, 3), v(3, 1)]) const { state } = step(s, { id: 'throw', target: v(3, 1) }) expect(state.bombs).toEqual([{ pos: v(3, 1), timer: 3, fire: 1, placedTurn: 5 }]) @@ -449,7 +461,7 @@ describe('action charges', () => { ####### #P...3# #######`) - s.charges.throw = 1 + s.powers.throw = true expect(isAvailable(s, 'throw')).toBe(false) }) @@ -479,14 +491,20 @@ describe('action charges', () => { expect(state.enemies).toEqual([]) // the flame got the Blob on the player's tile }) - it('freeze: enemies skip the move they announced', () => { - const s = intend(board(` - ###### - #P..B# - ######`), v(3, 1)) + it(`freeze: enemies skip this turn's move and the next ${FREEZE_TURNS - 1}, then move again`, () => { + let s = intend(board(` + ######## + #P....B# + ########`), v(5, 1)) s.charges.freeze = 1 - const { state } = step(s, { id: 'freeze' }) - expect(state.enemies[0]!.pos).toEqual(v(4, 1)) - expect(state.frozen).toBe(false) + s = step(s, { id: 'freeze' }).state + expect(s.frozen).toBe(FREEZE_TURNS - 1) + for (let i = 1; i < FREEZE_TURNS; i++) { + s = step(s, { id: 'wait' }).state + expect(s.enemies[0]!.pos).toEqual(v(6, 1)) + } + expect(s.frozen).toBe(0) + s = step(s, { id: 'wait' }).state + expect(s.enemies[0]!.pos).not.toEqual(v(6, 1)) }) }) diff --git a/test/helpers.ts b/test/helpers.ts index b325bae..d8bbb89 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -20,7 +20,7 @@ export function board(picture: string, stats: Partial<Stats> = {}): GameState { seed: 1, options: { ...DEFAULT_OPTIONS }, level: 1, turn: 5, width, height, cells, player: { x: 0, y: 0 }, lives: START_LIVES, stats: { ...START_STATS, ...stats }, bombs: [], status: 'playing', enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 100, coins: 0, - charges: noCharges(), shield: false, frozen: false, decoy: null, burning: [], + charges: noCharges(), shield: false, frozen: 0, decoy: null, burning: [], powers: noPowers(), star: 0, powerUp: null, runTurns: 0, deaths: 0, crumbling: [], aiRng: 1, dropRng: 1, } rows.forEach((row, y) => { diff --git a/test/picks.test.ts b/test/picks.test.ts new file mode 100644 index 0000000..28ad3e6 --- /dev/null +++ b/test/picks.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { canPick, pickTargets, resolvePick } from '../src/sim/picks' +import { board, v } from './helpers' + +describe('Move stands for every way of moving', () => { + it('with nothing gained, only the 4 tiles next to you', () => { + const s = board(` + ##### + #...# + #.P.# + #...# + #####`) + expect(pickTargets(s, 'move')).toHaveLength(4) + expect(resolvePick(s, 'move', v(3, 2))).toBe('move') + expect(resolvePick(s, 'move', v(3, 1))).toBeNull() + }) + + it('with Run, 2 tiles straight is a run', () => { + const s = board(` + ###### + #P...# + ######`) + s.powers.run = true + expect(resolvePick(s, 'move', v(2, 1))).toBe('move') + expect(resolvePick(s, 'move', v(3, 1))).toBe('run') + }) + + it('with Kick, a bomb next to you is kicked', () => { + const s = board(` + ###### + #P3..# + ######`) + expect(resolvePick(s, 'move', v(2, 1))).toBeNull() + s.powers.kick = true + expect(resolvePick(s, 'move', v(2, 1))).toBe('kick') + // With Bomb Pass too, walking into it still kicks it. + s.powers.bombPass = true + expect(resolvePick(s, 'move', v(2, 1))).toBe('kick') + }) + + it('with Bomb Pass, a bomb that cannot slide is walked over', () => { + const s = board(` + ##### + #P3+# + #####`) + s.powers.kick = true + s.powers.bombPass = true + expect(resolvePick(s, 'move', v(2, 1))).toBe('move') + }) + + it('with Teleport, up to 3 steps away over anything; a plain step is still a step', () => { + const s = board(` + ####### + #P#+..# + #######`) + s.powers.teleport = true + expect(resolvePick(s, 'move', v(4, 1))).toBe('teleport') + expect(resolvePick(s, 'move', v(5, 1))).toBeNull() + }) + + it('a diagonal step spends a Diagonal charge only when nothing free reaches the tile', () => { + const s = board(` + ##### + #P#.# + ##..# + #####`) + s.charges.diagonal = 1 + expect(resolvePick(s, 'move', v(2, 2))).toBe('diagonal') + s.powers.teleport = true + expect(resolvePick(s, 'move', v(2, 2))).toBe('teleport') + }) +}) + +describe('Bomb stands for placing and throwing', () => { + it('next to you is a plain bomb; with Throw, 2 tiles away over anything is a throw', () => { + const s = board(` + ###### + #P#..# + ######`) + expect(pickTargets(s, 'bomb')).toEqual([]) + expect(canPick(s, 'bomb')).toBe(false) + s.powers.throw = true + expect(canPick(s, 'bomb')).toBe(true) + expect(resolvePick(s, 'bomb', v(3, 1))).toBe('throw') + }) + + it('other actions stand for just themselves', () => { + const s = board(` + ##### + #P..# + #####`) + s.charges.pierce = 1 + expect(resolvePick(s, 'pierce', v(2, 1))).toBe('pierce') + expect(resolvePick(s, 'pierce', v(3, 1))).toBeNull() + }) +}) diff --git a/test/powerups.test.ts b/test/powerups.test.ts index 1598aea..34eb212 100644 --- a/test/powerups.test.ts +++ b/test/powerups.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { ACTIONS, isAvailable } from '../src/sim/actions' -import { generateLevel, LAST_LEVEL, skatesLevel } from '../src/sim/levelgen' +import { generateLevel, LAST_LEVEL, runLevel } from '../src/sim/levelgen' import { newRun } from '../src/sim/state' import { COIN_BAG, EXIT_SPAWN, STAR_TURNS, step } from '../src/sim/turn' import type { GameState, PowerUpKind } from '../src/sim/types' @@ -15,12 +15,12 @@ describe('levelgen: the hidden power-up', () => { }) } - it('Skates come on level 1 or 2, and only there', () => { + it('Run comes on level 1 or 2, and only there', () => { for (let seed = 1; seed <= 50; seed++) { - const at = skatesLevel(seed) + const at = runLevel(seed) expect([1, 2]).toContain(at) - expect(generateLevel(seed, at).powerUp.kind).toBe('skates') - for (let level = 1; level <= 12; level++) if (level !== at) expect(generateLevel(seed, level).powerUp.kind).not.toBe('skates') + expect(generateLevel(seed, at).powerUp.kind).toBe('run') + for (let level = 1; level <= 12; level++) if (level !== at) expect(generateLevel(seed, level).powerUp.kind).not.toBe('run') } }) }) @@ -52,11 +52,14 @@ describe('picking up power-ups', () => { }) it('abilities, and the event; the power-up is gone afterwards', () => { - const r = take('skates') + const r = take('run') expect(r.state.powers.run).toBe(true) expect(r.state.powerUp).toBeNull() - expect(r.events).toContainEqual({ type: 'powerUpTaken', powerUp: { pos: v(2, 1), kind: 'skates' } }) + expect(r.events).toContainEqual({ type: 'powerUpTaken', powerUp: { pos: v(2, 1), kind: 'run' } }) expect(take('detonator').state.powers.detonate).toBe(true) + expect(take('kick').state.powers.kick).toBe(true) + expect(take('throw').state.powers.throw).toBe(true) + expect(take('teleport').state.powers.teleport).toBe(true) }) it('a power-up still under its block cannot be taken', () => { @@ -92,7 +95,7 @@ describe('picking up power-ups', () => { }) }) -describe('Run (Skates)', () => { +describe('Run', () => { it('goes 2 tiles in a straight line, only over free tiles', () => { const s = board(` ####### @@ -175,6 +178,20 @@ describe('passes', () => { expect(step(e, { id: 'move', target: v(2, 1) }).state.status).toBe('dead') }) + it('fire can be stepped into only while it is safe: Flame Pass or a Star', () => { + const s = board(` + ##### + #P..# + #####`) + s.burning = [s.width + 2] + expect(ACTIONS.move.validTargets(s)).toEqual([]) + s.star = 2 + expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)]) + s.star = 0 + s.powers.flamePass = true + expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)]) + }) + it('Star: nothing hurts while it lasts', () => { const s = board(` ##### @@ -184,16 +201,16 @@ describe('passes', () => { expect(step(s, { id: 'move', target: v(2, 1) }).state.status).toBe('playing') }) - it('a death loses the passes, Detonator and Star, but keeps Skates and stats', () => { + it('a death loses the passes, Detonator and Star, but keeps Run, Kick, Throw, Teleport and stats', () => { const s = { ...board(` ##### #P1.# #####`, { capacity: 2 }), star: 0 } - s.powers = { run: true, detonate: true, wallPass: true, bombPass: true, flamePass: false } + s.powers = { run: true, kick: true, throw: true, teleport: true, detonate: true, wallPass: true, bombPass: true, flamePass: false } const dead = step(s, { id: 'wait' }).state expect(dead.deaths).toBe(1) const again = step(dead, { id: 'continue' }).state - expect(again.powers).toEqual({ run: true, detonate: false, wallPass: false, bombPass: false, flamePass: false }) + expect(again.powers).toEqual({ run: true, kick: true, throw: true, teleport: true, detonate: false, wallPass: false, bombPass: false, flamePass: false }) expect(again.stats.capacity).toBe(2) }) diff --git a/tools/compose-icons.sh b/tools/compose-icons.sh new file mode 100644 index 0000000..2ba826b --- /dev/null +++ b/tools/compose-icons.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Builds the power-up icons that are a known sprite plus a modifier badge in the upper right +# (user request, 2026-10-22): a green disc with a dark rim, the modifier's white symbol on it. +# Sources are the Kenney files already in assets/; the results go to assets/composed/ and are +# committed, so the build needs nothing extra. Needs ImageMagick 7 (`magick`). +# +# sh tools/compose-icons.sh +set -e +cd "$(dirname "$0")/.." +K=assets/kenney +I=assets/kenney-icons/White +B=assets/kenney-board-icons +OUT=assets/composed +mkdir -p "$OUT" + +# ink <white icon> <name>: the same shape in the UI's ink colour, for light backgrounds (CSS +# turns it white again where the background is dark: class "mono" in controls.ts). +ink() { + magick "$1" -fill '#353541' -colorize 100 "$OUT/$2.png" + echo " $OUT/$2.png" +} + +# The Move and Wait buttons (user request, 2026-10-22: the astronaut was confusing next to the +# player). Wait is the hourglass (it was the figure with an ✕ for a while). +ink "$B/character.png" icon_move +ink "$B/hourglass.png" icon_wait +# The fuse in the top bar and its info: the timer of the Fuse +1 badge (user request, 2026-10-22). +ink "$B/timer_CW_75.png" icon_fuse + +# badge <base sprite> <symbol> <name>: 128 × 128, the badge 56 px across at the top right. +badge() { + magick "$1" \ + \( -size 128x128 xc:none -fill '#2cc987' -stroke '#353541' -strokewidth 6 -draw 'circle 96,32 96,4' \) -composite \ + \( "$2" -resize 36x36 \) -geometry +78+14 -composite \ + "$OUT/$3.png" + echo " $OUT/$3.png" +} + +badge "$K/bomb.png" "$I/plus.png" powerup_bomb_plus +badge "$K/bomb.png" "$I/larger.png" powerup_fire_plus +badge "$K/bomb.png" "$B/timer_CW_75.png" powerup_fuse_plus +badge "$OUT/icon_move.png" "$I/fastForward.png" powerup_run +badge "$K/bomb.png" "$I/arrowRight.png" powerup_kick +badge "$K/bomb.png" "$I/upRight.png" powerup_throw +badge "$OUT/icon_move.png" "$I/export.png" powerup_teleport +badge "$K/bomb.png" "$I/exclamation.png" powerup_detonator +badge "$K/block_planks.png" "$I/cross.png" powerup_wall_pass +badge "$K/bomb.png" "$B/shield.png" powerup_bomb_pass +badge "$K/fireball.png" "$B/shield.png" powerup_flame_pass diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index b9c8ded..b056494 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -11,6 +11,8 @@ import { preview } from 'vite' const OUT = 'docs/screenshots' const PHONE = { width: 390, height: 844 } const DESKTOP = { width: 1280, height: 720 } +/** Phone-wide and tall enough for a whole almanac list. */ +const TALL = { width: 390, height: 2100 } const press = (...keys) => async (page) => { for (const k of keys) { @@ -63,7 +65,7 @@ const SHOTS = [ }, // A Pierce bomb (spikes badge) placed to the right, then a Decoy (pale copy of the player) below. { name: 'pierce-decoy', query: 'charges=2', act: press('KeyI', 'KeyD', 'KeyY', 'KeyS') }, - // Seed 55 hides Skates (a green gem) under the crate two tiles below the start: bomb it open + // Seed 55 hides Run under the crate two tiles below the start: bomb it open // and step back once the flame has died down... { name: 'power-up', query: 'seed=55', act: press('KeyB', 'KeyS', 'KeyD', 'KeyD', 'Space', 'KeyA') }, // ...then walk back and take it: Run appears, with its 2-tile targets picked. @@ -77,10 +79,10 @@ const SHOTS = [ { name: 'small-desktop', viewport: DESKTOP, query: 'small', act: async () => {} }, // Developer mode: every ability and unlimited charges (the ∞ badges); its tag in the top bar. { name: 'dev', query: 'dev', act: async () => {} }, - // "What is that?" (the ? button beside Wait) on, then tapping an enemy explains it; the top bar's items + // "What is that?" (the ? button beside the menu) on, then tapping an enemy explains it; the top bar's items // explain themselves with a plain tap. - { name: 'info-mode', act: async (page) => { await page.click('.inspect') } }, - { name: 'info-enemy', act: async (page) => { await page.click('.inspect'); await tap(4, 5)(page) } }, + { name: 'info-mode', act: async (page) => { await page.click('#info-button') } }, + { name: 'info-enemy', act: async (page) => { await page.click('#info-button'); await tap(4, 5)(page) } }, { 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') } }, @@ -91,8 +93,21 @@ const SHOTS = [ 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]') } }, + // The almanac (menu → Almanac), one shot per tab, in a tall window so each list shows whole. + ...['powerUps', 'charges', 'enemies'].map((tab) => ({ + name: tab === 'powerUps' ? 'almanac' : `almanac-${tab}`, + viewport: TALL, + act: async (page) => { + await page.click('#menu-button') + await page.click('#dialog-buttons button:has-text("Almanac")') + await page.waitForTimeout(300) + await page.click(`[data-tab=${tab}]`) + }, + })), + // Holding action charges: their buttons appear; Bomb picked offers the charge bombs as choices. + { name: 'charges', query: 'charges=2', act: async (page) => { await page.click('[data-action=bomb]') } }, + // Move picked with every upgrade (Developer mode): steps, runs, teleports, and the explanation. + { name: 'move-picked', query: 'dev', act: async (page) => { await page.click('[data-action=move]') } }, ] mkdirSync(OUT, { recursive: true })