explodorama / commit
Gems, charges on pickup, a demo, installable, and a day of playtest fixes
CHANGELOG.md | 57 +++- CONTRIBUTING.md | 7 + PLAN.md | 143 ++++++--- README.md | 211 +++++++------ assets/composed/charge_decoy.png | Bin 0 -> 1338 bytes assets/composed/charge_freeze.png | Bin 0 -> 3849 bytes assets/composed/charge_nuke.png | Bin 0 -> 6579 bytes assets/composed/charge_shield.png | Bin 0 -> 3092 bytes assets/composed/powerup_diagonal.png | Bin 0 -> 2484 bytes assets/kenney-board-icons/arrow_diagonal_cross.png | Bin 0 -> 787 bytes assets/kenney-board-icons/pawns.png | Bin 0 -> 1108 bytes assets/kenney-board-icons/skull.png | Bin 0 -> 935 bytes assets/kenney-board-icons/timer_0.png | Bin 0 -> 1271 bytes assets/kenney-icons/White/locked.png | Bin 0 -> 15262 bytes assets/kenney/door_closed.png | Bin 659 -> 0 bytes assets/kenney/door_open.png | Bin 141 -> 0 bytes assets/kenney/gem_blue.png | Bin 0 -> 1436 bytes assets/kenney/gem_green.png | Bin 0 -> 1421 bytes assets/kenney/gem_red.png | Bin 0 -> 1434 bytes assets/kenney/gem_yellow.png | Bin 0 -> 1394 bytes decisions.md | 325 ++++++++++++++++++++- docs/screenshots/almanac-charges.png | Bin 363678 -> 369548 bytes docs/screenshots/almanac-enemies.png | Bin 300151 -> 309080 bytes docs/screenshots/almanac.png | Bin 432463 -> 429437 bytes docs/screenshots/auto-move.png | Bin 247153 -> 244640 bytes docs/screenshots/bomb-picked.png | Bin 263510 -> 243597 bytes docs/screenshots/charges.png | Bin 287254 -> 256646 bytes docs/screenshots/countdown.png | Bin 249116 -> 248348 bytes docs/screenshots/death.png | Bin 193047 -> 192554 bytes docs/screenshots/demo.png | Bin 0 -> 104147 bytes docs/screenshots/desktop.png | Bin 583475 -> 583108 bytes docs/screenshots/dev.png | Bin 262006 -> 245790 bytes docs/screenshots/enemies.png | Bin 247378 -> 238019 bytes docs/screenshots/gem-offer.png | Bin 0 -> 196925 bytes docs/screenshots/gem.png | Bin 0 -> 244285 bytes docs/screenshots/info-enemy.png | Bin 183045 -> 181667 bytes docs/screenshots/info-hud.png | Bin 173449 -> 172465 bytes docs/screenshots/info-mode.png | Bin 243316 -> 242881 bytes docs/screenshots/lingering.png | Bin 249196 -> 248622 bytes docs/screenshots/long-press.png | Bin 247665 -> 247145 bytes docs/screenshots/menu.png | Bin 173505 -> 170784 bytes docs/screenshots/move-picked.png | Bin 306483 -> 243624 bytes docs/screenshots/new-run.png | Bin 184871 -> 179557 bytes docs/screenshots/nuke.png | Bin 0 -> 264697 bytes docs/screenshots/pierce-decoy.png | Bin 265585 -> 0 bytes docs/screenshots/power-up.png | Bin 251463 -> 0 bytes docs/screenshots/run.png | Bin 168533 -> 0 bytes docs/screenshots/scrolled.png | Bin 252804 -> 246848 bytes docs/screenshots/small-desktop.png | Bin 271844 -> 271684 bytes docs/screenshots/small.png | Bin 186025 -> 186731 bytes docs/screenshots/start.png | Bin 243519 -> 243146 bytes index.html | 29 +- package.json | 2 +- src/devlog.ts | 18 ++ src/main.ts | 255 +++++++++++++--- src/render/board.ts | 60 ++-- src/render/effects.ts | 11 +- src/render/sprites.ts | 63 ++-- src/sim/actions.ts | 132 +++------ src/sim/demo.ts | 162 ++++++++++ src/sim/enemies.ts | 65 ++++- src/sim/explosions.ts | 17 +- src/sim/levelgen.ts | 32 +- src/sim/path.ts | 7 +- src/sim/record.ts | 2 +- src/sim/state.ts | 62 ++-- src/sim/turn.ts | 85 +++++- src/sim/types.ts | 94 ++++-- src/style.css | 268 ++++++++++------- src/ui/almanac.ts | 7 +- src/ui/controls.ts | 107 ++----- src/ui/dialog.ts | 15 +- src/ui/info.ts | 145 +++++---- src/ui/offer.ts | 36 +++ static/icons/apple-touch-icon.png | Bin 0 -> 25301 bytes static/icons/icon-192.png | Bin 0 -> 28779 bytes static/icons/icon-512.png | Bin 0 -> 162267 bytes static/icons/icon-maskable-512.png | Bin 0 -> 101318 bytes static/manifest.webmanifest | 16 + static/sw.js | 26 ++ test/charges.test.ts | 199 +++++++------ test/demo.test.ts | 43 +++ test/enemies.test.ts | 141 +++++++-- test/helpers.ts | 8 +- test/options.test.ts | 40 ++- test/path.test.ts | 29 ++ test/picks.test.ts | 10 +- test/powerups.test.ts | 167 ++++++++--- test/sprites.test.ts | 23 ++ tools/compose-icons.sh | 29 +- tools/screenshots.mjs | 31 +- vite.config.ts | 8 +- 92 files changed, 2286 insertions(+), 901 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7167777..858bd41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,11 +26,16 @@ saved before the change is dropped and the next visit starts a new run. out 3 enemies. A par turn count, with a 5-coin bonus for beating it. - **Enemies:** Blob, Seeker and Drifter. Each commits to 3 moves ahead, shown as a fading column of arrows. Coin and charge drops; drops never stack on one tile. -- **Action charges:** Shield, Freeze, Diagonal, Pierce, Power, Line and Decoy, with count - badges on their icons. -- **Power-ups**, one per level under a crate: Bomb+, Fire+, Fuse+, Run, Detonator, Wall Pass, - Bomb Pass, Flame Pass, Star, Heart, Coin bag, Kick, Throw and Teleport. A death loses the - passes, the Detonator and a Star. +- **Charges**, dropped by enemies, that work as soon as you pick them up: Pierce, Power and the + new Nuke change your next 3 bombs; Shield blocks the next hit; Freeze freezes every enemy for + 3 turns; Decoy leaves a copy of you. + The top bar shows what each still has to give. +- **Power-ups:** Bomb+, Fire+, Fuse+, Run, Detonator, Wall Pass, Bomb Pass, Flame Pass, Star, + Heart, Coin bag, Kick, Throw, Teleport and Diagonal. A death costs only the life: every power-up + is kept. +- **A gem on every level**, hidden under a crate. Taking it offers 5 different power-ups, and + you choose one (no turn passes). Common power-ups are offered more often than rare ones, and + ones you already hold aren't offered. - **The 50-level run** with a victory banner at the end. - **Mid-run saving** after every action, in the browser. URLs with test parameters (`?seed`, `?level`, `?charges`, `?small`, `?dev`, `?god`, `?crumble`) never load or overwrite the save. @@ -44,31 +49,63 @@ saved before the change is dropped and the next visit starts a new run. things you can walk to offer **Walk there**. - **New run options:** a Normal board (31 × 13, 50 levels) or a Small one (9 × 11, 20 levels), plus Developer, God and Crumbling crates modes. -- **Pickup dialogs:** taking a power-up opens a dialog saying what it does; so does the first - pickup of each kind of action charge in a run. +- **Pickup dialogs:** the first pickup of each kind of action charge in a run opens a dialog + saying what it does. - **Animation** for the player, enemies, bombs, crates, flame, drops, power-ups, top-bar numbers and dialogs. - **Art** from Kenney's New Platformer Pack, UI Pack, UI Pack: RPG Expansion, Game Icons and Board Game Icons (all CC0). Power-ups that improve something get a green modifier badge. +- **A demo before the game:** scenes made up on the spot from the real rules (an enemy killed, a + narrow escape, a chain reaction, Freeze, the Detonator), each with a caption (and the icon of + the charge or power-up it's about), until you tap. +- **Installable** as an app (Chrome, Edge, Android, iPhone / iPad), and playable offline once + loaded. - **Deployment** to GitLab Pages on every push to the default branch, after the tests and the build pass. ### Changed - The placeholder shapes were replaced with Kenney sprites. -- The action bar is now only Move, Bomb and Wait, plus Shield, Freeze and Decoy while you hold - them. Upgrades make Move and Bomb do more instead of adding buttons. Picking an action shows a - strip that explains what it does right now. +- The action bar is now only Move, Bomb and Wait. Upgrades make Move and Bomb do more instead of + adding buttons, and charges have no buttons at all. - Kick, Throw and Teleport became power-ups kept for the whole run (they were charges). - Freeze now holds enemies for this turn and 3 more. - The Skates power-up is now called Run. - The outer wall is drawn as a bordered stone frame. +- The exit is a short, one-tile arched doorway instead of a squeezed two-tile door. +- Run is no longer guaranteed on level 1 or 2; it can be offered by any gem. +- Auto-move's way is shown as blue tiles instead of a flag and dots, and the Go button keeps + the walk icon. Setting the way takes the first step at once. +- The top bar has a fixed number of rows, so the board no longer moves as things are picked up. +- The Shield's ring is smaller and moves with the player. +- Stepping onto the exit walks into the doorway before the jump (it used to happen instantly). +- Saved runs from before the gems can't be resumed (`SIM_VERSION` 15). +- Developer mode now only puts every level's gem uncovered next to the player; it no longer + gives every ability, extra stats or unlimited charges. ### Removed +- The Line charge, and charges as actions with their own buttons and keys. +- The explanation strip above the action buttons. Nothing covers or resizes the board during + play; **?** then a button explains it. - The Fuse +1 / −1 charges. ### Fixed - Moving off the auto-move path now clears the flag. +- Enemies let out by flaming the exit or the gem are no longer killed at once by that flame, + which lingers a turn; a new explosion still kills them. +- The last enemy's charge is always one that lasts (Pierce, Power, Nuke or Shield), so it isn't + wasted. +- Enemies on the same tile no longer move as one: each plans around the others, so they spread out. +- A Freeze drop on the board crashed the game (its icon was the wrong size). +- An error while drawing a frame or playing a turn no longer freezes the game; it is logged and + play goes on. Under `npm run dev`, the game's logs and errors, from any device, show in the + terminal. +- The run's mode tags moved to the top bar's third row, so they can't push the level, enemies + and coins out of sight. +- Auto-move no longer walks over the open exit on the way somewhere else (which ended the + level early). - Thin light seams no longer run through the stone outer wall. +- Top-bar items (hearts, level, enemies and so on) no longer pulse on every update after they + have changed once; they pulse only when their value changes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f24274b..67ae61c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,9 +21,16 @@ npm install npm run dev # http://localhost:5173, plus a QR code for a phone on the same Wi-Fi npm test # Vitest npm run build # type-check and build into dist/ +npm run preview # serve the build, with a QR code for a phone on the same Wi-Fi npm run screenshots # build, then photograph the game into docs/screenshots/ (Playwright) ``` +**Seeing what went wrong:** under `npm run dev`, everything the game writes to the browser +console — and every error, caught or not — is printed in the terminal running `npm run dev`, +marked `(client)`, from any device, including a phone on the Wi-Fi. An error while drawing a +frame or playing a turn is caught, logged with its stack and the device (`src/devlog.ts`), and +the game carries on instead of freezing. + The URL parameters listed in the README (`?seed`, `?level`, `?charges`, `?small`, `?dev`, `?god`, `?crumble`) are the quickest way to reach the situation you are working on. diff --git a/PLAN.md b/PLAN.md index c47b107..84a1a05 100644 --- a/PLAN.md +++ b/PLAN.md @@ -80,7 +80,7 @@ Rules that fall out of this: | Fire (blast radius) | 1 | Tiles in each of 4 directions. | | Fuse | 3 | Starting timer of the player's bombs. | | Abilities | none | From pickups or the shop (§3.2, §5). | -| Action charges | none | Consumable actions dropped by enemies (§3.3). | +| Charges | none | Temporary changes dropped by enemies, working on pickup (§3.3). | | Coins | 0 | Spent in the shop (§8); leftovers go to the Workshop (§7). | | Curse | none | At most one active at a time (§6). | @@ -99,16 +99,22 @@ Starting values can be raised by Workshop tiers (§7). | **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. +has Move, Bomb and Wait. *(Until 2026-10-23 a second row held a button per charge held: +Shield, Freeze, Decoy, then also Pierce, Power and Line. Charges now work on pickup, §3.3, so +the bar is only Move, Bomb and Wait.)* *(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 +off from its button alone (Shield and Freeze took a second tap, or a **Use** tap in the strip, +while they were actions). 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 +(Teleport) and steps diagonally (while a Diagonal charge lasts); 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 +anything free before spending a charge. ~~Picking an action opens a **strip above the bar** +saying what it does now~~ *Removed 2026-10-23 (user request):* first it covered the board's +bottom row, then a band kept free for it kept resizing the board. There is no explanation text +during play: power-ups are explained in the gem's choice, charges on their first pickup, and +"?" then a button explains it (`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). @@ -157,30 +163,34 @@ they first appear on). | **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. | +| **Diagonal** | Diagonal power-up (*a charge until 2026-10-23*, user request; weight 8) | Step one tile diagonally; both orthogonal neighbours may be blocked — a squeeze. Part of Move (G, then Q E Z C). | | **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). +*Since 2026-10-23 (user request) every ability and pass is kept through a death.* (Until then Detonate and the passes were lost, the NES rule.) -### 3.3 Action charges (consumable, dropped by slain enemies) +### 3.3 Charges (dropped by slain enemies; temporary changes since 2026-10-23) -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). *Built (2026-10-22, user request):* the -first pickup of each kind of charge in a run opens a dialog saying what it does (`chargeInfo`), -like a power-up's; later pickups of that kind don't. +*Redesigned 2026-10-23 (user request):* charges are **no longer actions**. Each works the +moment it is picked up (`applyCharge` in `sim/actions.ts`), as a temporary change; there are no +charge buttons. What they still give lives in `state.boosts` (and `shield`, `frozen`, `decoy`) +and shows in the top bar with its count. It is kept from level to level and through a death. +Picking up more of a kind adds to it. The first pickup of each kind in a run still opens a +dialog saying what it does (`chargeInfo`). Icons are the user's choices (§13). -| Action | Rule | +| Charge | Rule | |---|---| -| ~~**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. | -| **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 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). +| **Pierce** | The next `BOMB_BOOST` (3, user's choice) bombs' flame passes **through** soft blocks. Icon: the Wall Pass icon. | +| **Power** | The next 3 bombs' flame reaches to the walls (soft blocks still stop it). Icon: the Fire+ icon. | +| **Nuke** | *New.* The next 3 bombs also burn every tile within `NUKE_RANGE` (2, user's choice over 3) steps, Manhattan, through anything; walls stand. Escapable: a fuse of 3 gives 3 steps. Icon: bomb + skull badge. | +| ~~**Diagonal**~~ | *Became a power-up the same day* (user request, §3.2). | +| **Shield** | Armed on pickup; absorbs the next hit (and anything else that turn), then is gone. Kept to the next level. Icon: the Move figure in an empty ring. | +| **Freeze** | Every enemy freezes for `FREEZE_TURNS` = 3 turns, the pickup turn included (was 4, as an action). Icon: a padlock. | +| **Decoy** | A decoy on the pickup tile (user's choice over "next to you"); chasing enemies target it for 3 turns. Flame destroys it. Icon: three pawns. | +| ~~**Line**~~ | *Removed 2026-10-23* (not in the user's new list). | +| ~~**Fuse +1 / Fuse −1**~~ | *Removed 2026-10-22 (user request).* | + +A bomb uses one of each bomb boost held, so Pierce, Power and Nuke combine on one bomb; it wears +their icons as badges. Charges can also appear in the shop (§8). --- @@ -215,7 +225,7 @@ 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) | **Run** (called Skates until 2026-10-22) | yes | -| Wallpass | **Wall Pass** — walk through soft blocks | no — always lost on death | +| Wallpass | **Wall Pass** — walk through soft blocks | no — always lost on death (*kept since 2026-10-23, see §9*) | | Detonator | **Detonator → unlocks Detonate** | no | | Bombpass | **Bomb Pass** — walk over bombs | no | | Flamepass | **Flame Pass** — immune to flame (tune later if too strong); walk through it (with a Star too, since 2026-10-22) | no | @@ -230,25 +240,37 @@ Additions: NES rules kept (they create the classic tension): - **One pickup per level** under a soft block (more via shop/Workshop upgrades), plus the - **exit door** under another soft block. + **exit door** under another soft block. *Since 2026-10-23 (user request) the pickup is a + **gem** that offers a choice:* taking it offers `OFFER_SIZE` (5) different power-ups, drawn + by the weights below without repeats and leaving out abilities and passes already held, and + the player picks one (`choose` action, no turn; `ui/offer.ts`, a dialog that must be + answered). The gem's colour is random and means nothing. The offer comes from the seed and + level (`stream(seed, 'offer', level)`). A death on the pickup turn drops the offer (the retry + has the gem back); reaching the exit on the same step still needs the choice first. - **Flame destroys an exposed pickup** — and spawns extra enemies, as in the NES. -- **Flaming the exit door** spawns a group of enemies from it. +- **Flaming the exit door** spawns a group of enemies from it. *Since 2026-10-23 (user's choice)* + they can't be hurt by the flame that let them out, which lingers a turn and used to kill them + at once; new explosions still can (`Enemy.sheltered`). The same for a flamed gem. - **The exit opens only when all enemies are dead.** -- **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. +- ~~**Run is guaranteed on level 1 or 2**~~ *Dropped 2026-10-23 (user's choice, with gems):* Run + is in the draw like the rest (weight 10). +- ~~*Built (2026-10-22, user request):* picking one up opens a dialog saying what it does~~ + *Replaced 2026-10-23:* the gem's choice dialog opens 0.45 s after the pickup instead, once the + player has arrived and the gem 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), 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" + Flame Pass 5, Detonator 9, Star 8 (8 turns), and since 2026-10-22 Kick 8, Throw 8, Teleport 3; + since 2026-10-23 Run 10, and they weight the gem's offer (`POWER_UP_WEIGHTS` in `sim/turn.ts`). 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, every power-up and pass (since 2026-10-23, user request; before, "as Keep" would). A Run collects what is on the tile it passes through. --- ## 5b. Exclusive power-ups — a roguelike direction (idea, user, 2026-10-21; *not decided*) +*2026-10-23:* the gem's choice of 1 from 5 (§5) is a first step in this direction — the player +chooses instead of taking whatever is there — but nothing is exclusive yet: slots are still open. + The user wants to start thinking about making power-ups **exclusive**: picking one up can **replace** another, so the player can't simply collect everything, and is pushed to **specialise, experiment and choose** — which to keep, which to drop. A sketch to discuss: @@ -275,8 +297,10 @@ these, not paper over them): 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 - is lost on death and the slot choice itself is the cost. -5. **Developer mode (§13b)** gives everything; it would bypass slots (fine for testing). + is lost on death and the slot choice itself is the cost. *Settled 2026-10-23 (user request): + nothing is lost on death, slots or not.* +5. ~~**Developer mode (§13b)** gives everything; it would bypass slots (fine for testing).~~ + *Resolved 2026-10-23:* Developer mode now only uncovers the gem, so it bypasses nothing. Open questions for the user: how many slots? Do stats take slots? Is a swapped-out power-up gone or left on the tile? Should action charges (one-use, from enemies) stay separate from slots @@ -365,7 +389,7 @@ enemies reset). Before that, a choice screen with two buttons, **both showing ex | | **Keep** | **Fresh** | |---|---|---| | Bomb +, Fire +, Fuse +, Run | kept | **reset to starting values** | -| Wall Pass, Bomb Pass, Flame Pass, Detonator, Star | lost (NES rule) | lost | +| Wall Pass, Bomb Pass, Flame Pass, Detonator, Star | lost (NES rule) — ***open:** since 2026-10-23 a death keeps them (user request), so this row, and what Keep vs Fresh costs, needs rethinking before §9 is built* | lost | | Coins | kept | kept, **plus a coin payout** | | Action charges | kept | kept | | Curse | removed | removed | @@ -408,7 +432,11 @@ contact. Intent shown every turn. Adapted from the NES eight: *Built (2026-10-20):* per type — Blob 20% coins / 15% charge, Seeker 50% / 25%, Drifter 20% / 50%; a coin drop is worth 1 (60%), 2 (30%) or 5 (10%). Counts: 3 enemies on level 1, +1 every 3 levels, at most 10. New type every 6 levels: Seeker from 7, Drifter from 13. - Enemies may share a tile (they don't block each other). Enemies spawned from a flamed exit + Enemies may share a tile (they don't block each other), but *since 2026-10-23 (user request)* + each plans around where the others will be, so they spread out and only share when forced + (`spreadSteps`). *Since 2026-10-23 (user's choice):* when a turn's kills leave no enemy, their + charge drops are only lasting ones (Pierce, Power, Nuke, Shield: `LASTING_CHARGES`), since a + Freeze or Decoy from the last enemy had nothing left to work on. Enemies spawned from a flamed exit drop nothing, so the exit can't be farmed. - Drops land on the enemy's tile and stay until collected or flamed. *Built (2026-10-21, user request):* drops never stack — a drop whose tile is taken (by a drop, a bomb or the uncovered @@ -533,13 +561,13 @@ Sprite assignments (✅ = drawn now; the rest are copied and waiting for their f | ✅ 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 `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.) | +| ✅ exit (locked / open) | `door_closed_top` / `door_open_top` alone, filling the tile: a short arched doorway (user request, 2026-10-23; until then the two-tile door with `door_closed` / `door_open`, squeezed into one tile, which the user found too tall). (`lock_yellow` spare.) | | ✅ coins | `coin_bronze` (1), `coin_silver` (2), `coin_gold` (5), bobbing; `hud_coin` in the HUD | -| ✅ 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) | +| ✅ charges | *Since 2026-10-23 (user's choices):* Pierce `powerup_wall_pass`, Power `powerup_fire_plus`, Nuke `charge_nuke` (bomb + Board Game Icons `skull` badge), Diagonal `charge_diagonal` (Move figure + `arrow_diagonal_cross` badge), Shield `charge_shield` (Move figure in the empty ring `timer_0`), Freeze `charge_freeze` (Game Icons `locked`), Decoy `charge_decoy` (`pawns`); built by `tools/compose-icons.sh`. On drops, in the top bar, and Pierce / Power / Nuke as badges on their bombs. (Until then: `switch_blue`, `chain`, `ramp`, `spikes`, `weight`, `bridge_logs`, `hud_player_helmet_green`.) | | ✅ decoy | `character_green_front`, faded | -| ✅ auto-move flag | `flag_green_a` | +| ✅ auto-move | The Go button keeps Move's walk icon, with a green border (until 2026-10-23 it turned into `flag_green_a`). On the board, since 2026-10-23 (user request), the whole way is highlighted blue, destination included, with no flag on the destination and no dots. | | ✅ intent arrows | drawn red lines with a head, not sprites | -| ✅ 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. | +| ✅ 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; in the gem's choice dialog and the almanac. On the board, since 2026-10-23, every level's pickup is a gem, `gem_blue` / `gem_green` / `gem_red` / `gem_yellow` (random colour), with no glow (the yellow glow was removed on 2026-10-23, user request). (Replaced and removed on 2026-10-22: the gems as fixed power-ups, `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 | @@ -555,6 +583,24 @@ settings, sound, and most action charges) are still open. --- +## 13a. The demo (user request, 2026-10-23; built) + +Some players never played the original, so the game opens with a demo: generated scenes, +replayed view-only, a caption under each, until the player taps or presses a key (that input +does nothing else; then the saved run, or a new one). Scenes are made **at play time** +(`sim/demo.ts`, pure and seeded): a 9 × 7 room (walls, pillars on every other tile, crates on +about one tile in six, the player and 1–3 enemies apart), then up to 150 random games on it by +the real rules — mostly moves, sometimes bombs, never a move that kills the player — until one +shows the scene's moment; the shortest of those is the scene (a new room if none does). +Kinds: **kill** (every enemy dead), **escape** (the player steps out of a blast on the turn it +goes off), **chain** (two bombs go off together and kill), **freeze** (a Freeze picked up, then +a frozen enemy killed), **detonate** (the Detonator sets a bomb off and it kills). All five are +found on every seed tried, in under 0.2 s. The UI (`main.ts`: `startDemo`, `demoTick`) plays a +turn every 0.6 s through the same animation code as play, hides the top bar and buttons, and +fits the room above the caption panel. A scene about a charge or power-up (Freeze, the Detonator) +shows its icon beside the caption, the same icon as on the board, in the top bar and the almanac. Ideas: more kinds (the exit, a gem's choice, Kick and +Throw), and showing it again after the player is idle on the menu. + ## 13b. Run options (user request, 2026-10-21; built) Chosen in the **New run** dialog (menu, or after game over / victory), remembered in @@ -565,7 +611,7 @@ and state (`GameState.options`), so replays are exact. Shown as tags in the top |---|---| | **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 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. | +| **Developer** | *Since 2026-10-23 (user request), only this:* every level's gem lies uncovered right of the start, every level and after every death, to try the choice at once. (Until then: every ability and pass, 5 bombs, blast 5, unlimited charges shown as ∞; **killable**, split from God mode by the user.) | | **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. | @@ -672,7 +718,7 @@ src/ assets/kenney/ Kenney New Platformer Pack sprites + License.txt (§13) static/ Vite publicDir (NOT public/ — see §17) test/ Vitest -tools/ icons.mjs (PWA icons), screenshots.mjs (Playwright) +tools/ compose-icons.sh (composed sprites and the app icons), screenshots.mjs (Playwright) ``` - `step()` **returns events** (`moved`, `bombPlaced`, `bombTicked`, `exploded`, `blockDestroyed`, @@ -697,7 +743,10 @@ tools/ icons.mjs (PWA icons), screenshots.mjs (Playwright) - `tsconfig.json`: copy nordic-skating's (strict, `noUncheckedIndexedAccess`, …). - `vite.config.ts`: `base: process.env.BASE || './'`, `publicDir: 'static'`, QR plugin, PWA (display fullscreen, icons, offline cache incl. the sprites). Read nordic-skating's file — its - comments explain each choice. + comments explain each choice. *Built 2026-10-23 (user request) without a PWA plugin:* + `static/manifest.webmanifest` (display fullscreen), icons in `static/icons/` from + `tools/compose-icons.sh`, and `static/sw.js`, a network-first service worker that keeps a copy + of everything loaded (so the game starts offline) and registers only in the build. - `.gitlab-ci.yml`: copy nordic-skating's (test stage, then `pages` with `mv dist public` and the "fail loudly" checks; add a check that the sprites were built). Project at `gitlab.com/PavelSafronov/explodorama`, served at `https://pavelsafronov.gitlab.io/explodorama/`. @@ -723,12 +772,12 @@ 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, Run on level 1 or 2), +7. **M6 – Run loop:** ✅ NES pickups (all 11, one hidden per level, Run on level 1 or 2; since 2026-10-23 a gem offering 5 to choose from, no Run guarantee), 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 goes away on the board, the top bar's numbers, the banner, and dialogs opening and closing); - ⏳ sounds (Kenney pack, §13), PWA icons, remaining + ✅ installable with offline play (2026-10-23); ✅ the demo before the game (2026-10-23, §13a); ⏳ sounds (Kenney pack, §13), remaining enemies and actions, screenshots tool. 9. **M8 – Permanence:** Workshop, save export/import, victory certificate + QR, replay file and verify page. diff --git a/README.md b/README.md index e773981..ee44415 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ counts down with *your* moves, so nothing happens until you act — and you can what will burn before you commit. Status: **early development.** The board, bombs, lives, the first three enemy types, coins, all -eleven action charges, hidden power-ups, the exit, the 50-level run with its ending, and saving +seven charges that work when picked up, a hidden gem on every level that offers a choice of power-ups, the exit, the 50-level run with its ending, and saving mid-run work. The shop, the Keep / Fresh choice on death, curses and more enemies are planned — see [PLAN.md](PLAN.md). @@ -15,10 +15,19 @@ see [PLAN.md](PLAN.md). ## How to play +**The demo:** when the game opens, it first plays a demo — short scenes made up on the spot from +the real rules, each with a caption: bombs counting down and killing enemies, a narrow escape +from a blast, a chain reaction, Freeze, and the Detonator; a scene about a charge or power-up +shows its icon beside the caption. Every time it's different. It is +view-only; **tap anywhere (or press any key) to play**, and that tap does nothing else. A test +URL (`?seed` and the like) skips it; `?demo` shows it anyway. + +<img src="docs/screenshots/demo.png" alt="The demo: a bomb about to go off in a small room, with the caption underneath" width="260"> + You are the green astronaut (animated: it bobs while waiting, hops from tile to tile, crouches -to place a bomb and jumps when a level is cleared; enemies hop between tiles too, and flip over +to place a bomb, and walks into the doorway and jumps when a level is cleared; enemies hop between tiles too, and flip over when they die). Everything else on the board moves too: bombs pop in, swell as they count down, -slide when kicked and fly when thrown; crates burst; flame fades out; drops and power-ups rise +slide when kicked and fly when thrown; crates burst; flame fades out; drops and the gem rise away when taken. A number in the top bar bumps when it changes, and dialogs pop up and shrink away. **Goal of each level:** destroy every enemy, uncover the exit door hidden under one of the crates, and step onto it. @@ -27,48 +36,52 @@ Each turn you pick **one** action; then the world takes its turn, in this order: 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. += Wait). Picking one turns it green and highlights the tiles it can use. There is **no +explanation text during play** (user request): what each power-up and charge does is shown when +you first get it, and **?** then any button explains that button. 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, 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. | +| **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:** or a diagonal step. | +| **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. Pierce, Power and Nuke charges change your next bombs by themselves (see Charges). | | **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. +throw. A diagonal step is only taken when nothing else 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 -step** along the way (one turn, as always — the world still moves between steps). The way is -worked out again every turn. Tapping another far tile moves the flag there. Auto-move ends when -you arrive, when you **move somewhere off the dotted way** (you changed your mind), when the flag -can no longer be reached, when you tap a wall or crate, or on **Esc**. +gem or a drop. **You take the first step at once**, just as a tap next to you would (if that +step is safe). Every tile of the rest of the way, including where you're going, is highlighted +blue, and the **Move** button becomes **Go** (same walk icon, with a green border): each press of +Go — or each tap on the destination itself — takes **one step** along the way (one turn, as always — the world still +moves between steps). The way is worked out again every turn. Tapping another far tile sets a +new way. Auto-move ends when you arrive, when you **move somewhere off the blue way** (you +changed your mind), when the destination can no longer be reached, when you tap a wall or crate, or on **Esc**. The way **never crosses +the open exit** (that would end the level on the way); it only steps onto the exit when the +exit is where you are going. If the only way somewhere is over the open exit, auto-move can't go there. It **won't take a deadly step**: onto an enemy, onto a tile an enemy's arrow points at, or into -flame this turn. The next step then shows red, and Go / the flag wait for you to deal with it +flame this turn. The next step then shows red, and Go / the destination 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 +**Once every enemy is gone**, the way needs no more presses: you walk there by yourself, one 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; and with Kick, tapping a bomb next to you kicks it) — a tap +Tapping an enemy, a bomb, burning flame, a wall or a crate does nothing (apart from ending +auto-move, 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 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 +burning flame, a wall or crate, the exit, the gem, 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 +to, the dialog has a **Walk there** button that starts auto-move there. The items in the **top bar** (lives, level, enemies left, coins, turn / par, bombs, blast, fuse, passes, Star) explain 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 the **Almanac** — every power-up, action charge and enemy type, with its +and seed, and has the **Almanac** — every power-up, 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. @@ -76,23 +89,24 @@ run can't be got back. 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; 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. The first time in a run you pick up each kind of charge, a -dialog says what it does; after that, picking up more of the same kind shows nothing. - -| Charge | Icon | Phone | Keyboard | What it does | -|---|---|---|---|---| -| **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. | +**Charges** are things enemies sometimes drop. They aren't actions and have no buttons: **each +works the moment you pick it up**, as a temporary change (user request, 2026-10-23). The top bar +shows what each still has to give — bombs or turns left — and tapping it there explains +it. Picking up more of a kind adds to it. What they still give goes on to the next level and +through a death. The first time in a run you pick up each kind, a dialog says what it does. +(Diagonal was a charge until 2026-10-23; it is a power-up now.) + +| Charge | Icon | What it does | +|---|---|---| +| **Pierce** | the Wall Pass icon (crate with ✕) | Your next **3 bombs'** flame goes **through** crates, burning every one in reach. | +| **Power** | the Fire +1 icon | Your next **3 bombs'** flame reaches all the way to the walls (crates still stop it). | +| **Nuke** | bomb with a skull badge | Your next **3 bombs** also burn **every tile within 2 steps** (across plus down), through crates and round corners; walls still stand. It can catch you too: get 3 steps away. | +| **Shield** | the Move figure in a ring | A ring appears round you: the next hit (flame or enemy) is absorbed, and so is anything else that hits you that turn. Then it's gone. | +| **Freeze** | a padlock | Every enemy freezes in place for **3 turns**, the pickup turn included: 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** | three pawns | A pale copy of you appears where you picked it up. Enemies that chase you (Seekers) chase it instead for 3 turns. Flame destroys it; it doesn't block anyone. | + +A bomb that Pierce, Power or Nuke changed wears their icons as small badges, and one bomb can +have all three: each bomb you place (or throw) uses one of each that you hold. 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. @@ -117,6 +131,8 @@ will end up, a faded bomb with its blast, a kicked bomb's stopping place, a chan (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. +- Pierce, Power and Nuke charges change your next bombs (see Charges above); such a bomb wears + their icons as badges. ### Danger @@ -134,70 +150,86 @@ of its tile: the top arrow is its next move (strongest), the ones below are the moves (fainter). It makes exactly those moves, one per turn, so you can time a bomb for where it will be. No arrows (and closed eyes): it has nowhere to go and stays put. If you put a bomb on a tile it planned to walk to, -it stops there and plans a new route. Touching an enemy costs a life — walking into it, it -walking into you, or the two of you swapping places. Flame kills enemies. +it stops there and plans a new route. Enemies keep clear of each other: each plans its moves +around where the others will be, so a group (like the 3 let out of a blasted exit) splits up +instead of moving as one; they only share a tile when there's nowhere else to go. Touching an +enemy costs a life — walking into it, it walking into you, or the two of you swapping places. +Flame kills enemies. | Enemy | Looks like | Appears from | Moves | |---|---|---|---| | **Blob** | purple slime | level 1 | Wanders; usually keeps going the way it was heading. | | **Seeker** | fly | level 7 | Half the time takes the shortest way toward you; otherwise wanders like a Blob. | -| **Drifter** | spinning saw | level 13 | Goes straight until blocked, then turns. | +| **Drifter** | spinning saw | level 13 | Goes straight until blocked (or another enemy is heading there), then turns. | Level 1 has 3 enemies, with one more every 3 levels (at most 10). Enemies never start within 4 tiles of you. ### Drops and coins -A slain enemy sometimes leaves **coins** (bronze = 1, silver = 2, gold = 5) or an **action -charge** on its tile. Drops never share a tile: if one is already there, the new one rolls to the +A slain enemy sometimes leaves **coins** (bronze = 1, silver = 2, gold = 5) or a **charge** +(which works as soon as you pick it up) on its tile. The last enemy's charge (when no enemies are +left after it) is always one that lasts into the next level — Pierce, Power, Nuke or Shield — +never a Freeze or Decoy, which would have nothing to work on. Drops never share a tile: if one is already there, the new one rolls to the nearest free floor tile (up to 2 steps away). Walk onto them to collect them. A later flame burns drops lying in the open, so pick them up soon. Coins will buy upgrades in the shop (not built yet). ### The exit -- The exit door is hidden under one of the crates. Burning that crate reveals it. +- The exit door (a short arched doorway: orange while locked, dark once open) is hidden under + one of the crates. Burning that crate reveals it. - The door stays **locked** while any enemy is alive (top-bar enemy count). When the last one dies, it opens; step onto it to finish the level. - **Don't blast the uncovered door:** flame on it lets out 3 more enemies (of the level's - toughest type). Those drop nothing. + toughest type). Those drop nothing. They can't be hurt by the flame that let them out (it + lingers a turn), but a new explosion kills them as usual. - **Par:** the top bar shows `turns taken / par`. Finish within par for a bonus of **5 coins**. Par is 50 turns plus 10 per enemy on the level. ### Power-ups -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. **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** | 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, 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. +Every level hides **one gem** under a crate (never the exit's). Burn the crate to reveal it — it +is in a random colour (the colour means nothing) — 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 (and, as there, +those enemies can't be hurt by the flame that let them out). + +**Taking the gem offers 5 different power-ups, and you choose one.** The dialog shows each with +what it does; it can't be closed without choosing, and choosing takes no turn. The 5 are drawn +at random, but not all are equally likely: common ones (Bomb +1, Fire +1) come up far more often +than rare ones (Teleport, Heart). An ability or pass you already hold is never offered. The +offer is fixed by the seed and level, so a retry after a death offers the same. If you die on the turn you take the gem, there +is no choice, and the retry has the gem back. If the same step also reaches the exit, you choose +before moving on. + +| Power-up | Looks like | Effect | +|---|---|---| +| **Bomb +1** | bomb with a + badge | One more bomb on the board at a time. | +| **Fire +1** | bomb with an expanding-arrows badge | Blast reaches one tile further. | +| **Fuse +1** | bomb with a timer badge | Your bombs start one higher (more time to get away). | +| **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. | +| **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. | +| **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). | +| **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). | +| **Diagonal** | the Move figure with a diagonal-arrows badge | **Move** can also step diagonally, even squeezing between two blocked tiles (or **G**, then **Q E Z C**: up-left, up-right, down-left, down-right). A charge until 2026-10-23. | +| **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. | +| **Wall Pass** | crate with an ✕ badge | Walk through crates. | +| **Bomb Pass** | bomb with a shield badge | Walk over bombs. | +| **Flame Pass** | flame with a shield badge | Flame doesn't hurt you (enemies still do), and you can walk through it. | +| **Star** | star | Nothing hurts you for 8 turns (a gold ring; the top bar counts down); you can walk through flame meanwhile. | +| **Heart** | heart | One more life. | +| **Coin bag** | coin | 10 coins. | + +Run, Kick, Throw, Teleport and Diagonal (they were one-use charges before) add no buttons — they +make Move and Bomb do more (**?** then the button says how). The Detonator adds none either: +your bombs pulse, and a tap on one sets it off. The top bar shows 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 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 +again with the same layout and enemies. **You keep everything else**: coins, stats, every +power-up (abilities and passes included) and what your charges still give. (The original took +the passes and the Detonator away; since 2026-10-23 a death costs only the life.) With none left, the run is over and the seed is shown, so the same board can be replayed. ### New run: board size and modes @@ -208,12 +240,12 @@ replayed. |---|---| | **Normal** board | 31 × 13, scrolls sideways, **50 levels**. | | **Small** board | 9 × 11, fits the screen whole (no scrolling), **20 levels**, fewer enemies (2 on level 1, up to 5) and fewer crates. A quicker game. | -| **Developer** (checkbox) | Every power-up and ability (Run, Detonate, all three passes), **unlimited** action charges (badges show ∞), 5 bombs, blast 5 — for trying everything out. You can still be killed. | +| **Developer** (checkbox) | **Every level's gem lies uncovered right next to you** at the start (and after a death), to try the power-up choice at once. Nothing else changes. | | **God** (checkbox) | Nothing can kill you. | -| **Crumbling crates** (checkbox) | Once the last enemy is gone, every crate crumbles away over 1–3 turns (shaking first), revealing the exit and the power-up. Not flame: nothing is punished. | +| **Crumbling crates** (checkbox) | Once the last enemy is gone, every crate crumbles away over 1–3 turns (shaking first), revealing the exit and the gem. Not flame: nothing is punished. | -The choice is remembered for next time, is fixed for the run, and shows as small yellow tags at -the left of the top bar (tap them for details) and in the menu. Starting a new run while one is +The choice is remembered for next time, is fixed for the run, and shows as small yellow tags at the start of +the top bar's last row (tap them for details) and in the menu. Starting a new run while one is going warns that it will be lost. ### The run @@ -225,7 +257,10 @@ coming back continues the run where you left it. ### The top bar Lives (hearts), level (flag), enemies left, coins, turn / par, your bomb limit, blast size and -fuse, then the passes you hold and a Star's countdown. +fuse, then — in their own row — the passes you hold, a Star's countdown, and what your charges +still give (bombs or turns left, or the Shield while it's up). The bar has a fixed number of +rows (three on a phone or a narrow screen, one on a wide one), and a row that gets too long +scrolls sideways instead of growing, so **the board never moves** as you pick things up. ### The board @@ -237,19 +272,29 @@ camera follows you sideways. ```sh npm install -npm run dev # http://localhost:5173 — also prints a QR code to open it on a phone on the same Wi-Fi +npm run dev # http://localhost:5173 — also prints a QR code to open it on a phone on the same Wi-Fi, + # and the game's console output and errors, from any device, in this terminal npm test # Vitest npm run build # type-check and build into dist/ +npm run preview # serve dist/ (also on the network, with a QR code), as it will be deployed npm run screenshots # build, then photograph the game into docs/screenshots/ (Playwright) ``` URL parameters: `?seed=123` plays a fixed board; `?level=4` starts at a later level (level 13 -has all three enemy types); `?charges=2` starts with 2 of every action charge, for trying them -out; `?small`, `?dev`, `?god` and `?crumble` set the board size and modes. A URL with any of these parameters plays a test board: it doesn't load or overwrite your +has all three enemy types); `?charges=2` starts as if 2 of each lasting charge had been picked up +(6 Pierce, Power and Nuke bombs, and a Shield), for trying them out; `?small`, `?dev`, `?god` and `?crumble` set the board size and modes; `?demo` shows the demo, which test URLs otherwise skip (with `?seed`, the same demo every time). A URL with any of these parameters plays a test board: it doesn't load or overwrite your saved run. Deployed to GitLab Pages by `.gitlab-ci.yml` on every push to the default branch. +**Installing it:** the game is an installable web app (`static/manifest.webmanifest`, icons in +`static/icons/`, built by `tools/compose-icons.sh`). In Chrome or Edge, use the install button in +the address bar (or the menu's "Install"); on Android, "Add to Home screen" / "Install app"; on an +iPhone or iPad, Safari's Share → "Add to Home Screen". It then opens in its own window, full +screen on a phone. A small service worker (`static/sw.js`, network first) keeps a copy of +everything it has loaded, so once played it also starts **offline**; online, it always loads the +latest version. + ## How it's built - **`src/sim/`** — all the game's rules, in plain TypeScript with no rendering. Deterministic: diff --git a/assets/composed/charge_decoy.png b/assets/composed/charge_decoy.png new file mode 100644 index 0000000..7faab89 Binary files /dev/null and b/assets/composed/charge_decoy.png differ diff --git a/assets/composed/charge_freeze.png b/assets/composed/charge_freeze.png new file mode 100644 index 0000000..72e8b92 Binary files /dev/null and b/assets/composed/charge_freeze.png differ diff --git a/assets/composed/charge_nuke.png b/assets/composed/charge_nuke.png new file mode 100644 index 0000000..b7a1031 Binary files /dev/null and b/assets/composed/charge_nuke.png differ diff --git a/assets/composed/charge_shield.png b/assets/composed/charge_shield.png new file mode 100644 index 0000000..3562213 Binary files /dev/null and b/assets/composed/charge_shield.png differ diff --git a/assets/composed/powerup_diagonal.png b/assets/composed/powerup_diagonal.png new file mode 100644 index 0000000..3135433 Binary files /dev/null and b/assets/composed/powerup_diagonal.png differ diff --git a/assets/kenney-board-icons/arrow_diagonal_cross.png b/assets/kenney-board-icons/arrow_diagonal_cross.png new file mode 100644 index 0000000..7f07fe1 Binary files /dev/null and b/assets/kenney-board-icons/arrow_diagonal_cross.png differ diff --git a/assets/kenney-board-icons/pawns.png b/assets/kenney-board-icons/pawns.png new file mode 100644 index 0000000..5da1582 Binary files /dev/null and b/assets/kenney-board-icons/pawns.png differ diff --git a/assets/kenney-board-icons/skull.png b/assets/kenney-board-icons/skull.png new file mode 100644 index 0000000..4be93c8 Binary files /dev/null and b/assets/kenney-board-icons/skull.png differ diff --git a/assets/kenney-board-icons/timer_0.png b/assets/kenney-board-icons/timer_0.png new file mode 100644 index 0000000..a33521d Binary files /dev/null and b/assets/kenney-board-icons/timer_0.png differ diff --git a/assets/kenney-icons/White/locked.png b/assets/kenney-icons/White/locked.png new file mode 100644 index 0000000..22189be Binary files /dev/null and b/assets/kenney-icons/White/locked.png differ diff --git a/assets/kenney/door_closed.png b/assets/kenney/door_closed.png deleted file mode 100644 index 93a02c6..0000000 Binary files a/assets/kenney/door_closed.png and /dev/null differ diff --git a/assets/kenney/door_open.png b/assets/kenney/door_open.png deleted file mode 100644 index b9c6f56..0000000 Binary files a/assets/kenney/door_open.png and /dev/null differ diff --git a/assets/kenney/gem_blue.png b/assets/kenney/gem_blue.png new file mode 100644 index 0000000..7cd3ec8 Binary files /dev/null and b/assets/kenney/gem_blue.png differ diff --git a/assets/kenney/gem_green.png b/assets/kenney/gem_green.png new file mode 100644 index 0000000..7ccce2e Binary files /dev/null and b/assets/kenney/gem_green.png differ diff --git a/assets/kenney/gem_red.png b/assets/kenney/gem_red.png new file mode 100644 index 0000000..953761c Binary files /dev/null and b/assets/kenney/gem_red.png differ diff --git a/assets/kenney/gem_yellow.png b/assets/kenney/gem_yellow.png new file mode 100644 index 0000000..1e7acfd Binary files /dev/null and b/assets/kenney/gem_yellow.png differ diff --git a/decisions.md b/decisions.md index b114970..efc27a3 100644 --- a/decisions.md +++ b/decisions.md @@ -322,7 +322,11 @@ bombs teleported), with flame fading out over about half a second. top-bar item that changed (a number, a heart, a pass arriving) is replaced and bumps (scales to 1.3 and back in 0.3 s); unchanged items are kept as they are, so a bump isn't cut short by the next update. Dialogs close with a 0.15 s shrink-and-fade — buttons, the close button, the - backdrop and Esc alike — and a button's action runs once it has closed. + backdrop and Esc alike — and a button's action runs once it has closed. *Fixed 2026-10-22 + (user report: hearts, flag and enemies pulsed every turn):* the kept items are re-attached on + every update, and one still carrying the `bump` class played it again each time, so anything + that had changed once kept pulsing. The class now comes off when the bump ends + (`animationend`). The turn counter still bumps every turn, because it changes every turn. - **No input lock** (PLAN.md planned `animate.ts` to own one). The game is turn-based and the sim has already moved on, so a quick next tap simply starts the next turn's animations over the end of the last ones; locking input would only slow play down. @@ -364,7 +368,8 @@ charges or permanent, kick on a plain tap, how far to fold, where to explain. **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 + edge, so the board doesn't move when it appears. *Superseded 2026-10-23: it must not cover the + board; see "The strip gets its own band".* 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. @@ -407,6 +412,8 @@ charges or permanent, kick on a plain tap, how far to fold, where to explain. ### 2026-10-22 — A dialog when a power-up is picked up +*Superseded 2026-10-23 by the gem's choice dialog (see "Gems: choose 1 of 5 power-ups").* + 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 @@ -506,3 +513,317 @@ screen, in the almanac, the docs, and the code (the power-up's id `skates` → ` 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. +### 2026-10-23 — Auto-move never crosses the open exit + +User report: the dotted way to a power-up went over the open exit, so walking it ended the +level without the power-up. `playerPath` now treats the open exit as blocked unless it is the +destination. If the only way somewhere is over it, there is no way (the flag can't be set): ending +the level by surprise is worse. A locked exit is still crossed, since stepping on it does +nothing; the way is worked out again every turn, so it goes round the exit as soon as it opens. + +### 2026-10-23 — The strip gets its own band + +*Superseded the same day: the strip is gone (see "No explanation strip during play").* + +User report: with the player on the bottom row, the explanation strip covered that row, so it +couldn't be tapped. The user's rule: **the strip must not cover any of the board.** Of the two +ways to do that, the user chose a band that is always kept free (over a board that shrinks only +while the strip is open). The strip is now part of the action bar, at its top; the board is +fitted above the whole bar, as before. + +How tall: the tallest strip that could open now (every action that can be picked, plus the one +open), measured off-screen (`reserve` in `ui/controls.ts`). Measured on a phone: 103 px with +nothing collected, 181 px holding Pierce / Power / Line (Bomb's choices), 264 px in Developer +mode; on a desktop about 105 px. Reserving the Developer-mode maximum always would have cost a +phone about a third of the board, so the band follows what the player holds: the board resizes +when an upgrade or a charge is gained or spent, never when an action is picked. It is measured +again on a window resize. The fade-in now plays only when the strip's content changes. + +### 2026-10-23 — Gems: choose 1 of 5 power-ups + +User request: instead of a fixed power-up, every level's pickup is a gem of random colour, and +taking it shows a choice of power-ups. User's answers on the open points: + +- **5 offered** (`OFFER_SIZE`), all different, **drawn with today's rarities** + (`POWER_UP_WEIGHTS`, now in `sim/turn.ts`), listed in the almanac's order. Abilities and passes + already held are left out (they would change nothing); if fewer than 5 remain, fewer are offered. +- **No Run guarantee** any more: Run is in the draw with weight 10 (it had none, being + guaranteed on level 1 or 2). `runLevel` is gone. +- **Choosing takes no turn and can't be put off**: the dialog has no close button, and Esc and + the backdrop don't close it (`required` in `ui/dialog.ts`). The world doesn't move meanwhile. + +How it works: taking the gem sets `state.offer` (the event `powerUpTaken` carries it); while it +is set, every action is unavailable except `{ id: 'choose', kind }`, which applies the power-up +and clears the offer without a turn passing. The choice is an action in the run record, so it +replays; a reload with a choice open shows it again. The offer comes from the seed and level, so +a retry after a death offers the same, and dying can't reroll it (unless the death cost a pass, +which may then be offered). A death on the pickup turn drops the offer, and the retry has the gem +back; reaching the exit on the same step (a Run over the gem) still needs the choice before the +next level. A Star chosen this way lasts its full 8 turns (taken mid-turn it had lost one). +The colour is only how it looks: `gem_blue` / `gem_green` / `gem_red` / `gem_yellow` from the New +Platformer Pack (the gems were power-up sprites until 2026-10-22). The old "You picked it up!" +dialog is gone; the choice already says what each does. `SIM_VERSION` 8 (9 since the shelter rule below): saved runs from before +are dropped (the record's meaning changed). Level layouts are unchanged: the colour uses the +level stream's last draw, where the power-up's kind used to be. + +Checked in a browser (a gem placed next to the player through a temporary hook, removed): the +dialog opens with 5 choices and no close button, stays open on Esc and on a backdrop tap, Wait +is refused meanwhile, choosing Fire +1 raised the blast from 1 to 2 with the turn unchanged, and +play went on. All 5 choices fit without scrolling on a 1280 × 720 desktop and a 390 × 844 phone. + +### 2026-10-23 — A short exit doorway + +User request: the exit (the pack's two-tile door squeezed into one tile) was too tall. It is now +only the door's arched top piece (`door_closed_top` / `door_open_top`), at full tile size: a +short doorway, orange while locked, dark once open. The bottom halves are no longer used and +were removed from `assets/kenney/`. + +### 2026-10-23 — No explanation strip during play + +User feedback on the band above: "the text box keeps resizing, that's definitely not going to +work" (the band followed what the player held, so the board changed size as charges came and +went). Since power-ups are now explained in the gem's choice and charges on their first pickup, +the user chose to **show no explanation text during play** at all. "?" then a button still +explains it (`actionInfo`). + +The strip also held two controls, which needed a new home (user's choices): + +- **Pierce, Power and Line get their own buttons** in the second row, like Shield, Freeze and + Decoy, shown while held, with a count: tap one, then a tile. Bomb no longer stands for them. +- **Shield and Freeze are used with a second tap on their button** (or Enter); the first tap + only picks them (green), and anything else cancels. Still never a single accidental tap. + +The second row now holds up to six buttons, so its buttons are a sixth wide (they were a fifth): +all six fit in one row even on a 320 px phone, so a third row never comes and goes. The row +itself still appears with the first charge held and goes with the last one, as before. +Checked in a browser with every charge held (390, 360 and 320 px wide): the bar didn't move +while picking Move, Bomb, Pierce, Line and Decoy or after using charges; one tap on Shield only +picked it and the second used it; Freeze with Enter; a Pierce bomb placed with its button and +an arrow key. The charge texts no longer say "a choice under Bomb", and the first-pickup dialog +and "?" say how each charge is used. + +### 2026-10-23 — Auto-move's way is blue tiles, with no flag + +User request: no flag on the destination; highlight the way's tiles blue. Every tile of the way, +the destination included, gets the same blue as a picked action's targets (the dots and the +flag sprite are gone). The next step still turns red when it isn't safe. Tapping the destination +still takes a step, as tapping the flag did. The Go button kept its flag icon at first; see the +next entry. + +### 2026-10-23 — Enemies let out by flame are sheltered from it + +User report: a bomb on the uncovered (locked) exit let out 3 enemies, and the remaining fire +killed them at once. Why: they appear on the exit tile at the end of the turn, and the blast +lingers one more turn (since 2026-10-20), so the next turn they stand or step inside it and die +— the NES punishment almost never bit. The same held for a flamed gem. + +User's choice (of: shelter them from that flame; let them out a turn later; immune to all flame +for a turn; put them outside the blast): **they can't be hurt by the flame that let them out, +but a new explosion kills them as usual.** `Enemy.sheltered` is set when they appear; on the next +turn only new flame counts against them, then it is cleared. Tests check both halves, and that +the first one fails without the rule. `SIM_VERSION` 9. + +### 2026-10-23 — Developer mode starts each level next to the gem + +User request. In Developer mode the level's gem lies uncovered right of the start (a tile that is +always open floor), every level and after every death, to try the choice without hunting for it. +The crate that hid it stays a crate. Normal runs are unchanged. + +### 2026-10-23 — Screenshots regenerated + +User request, after today's changes. `power-up` / `run` became `gem` (seed 55's gem uncovered) +and `gem-offer` (its choice); `charges` now shows the six charge buttons with Pierce picked (Bomb's +choices in the strip are gone); comments in `tools/screenshots.mjs` updated for the blue way and +the missing strip. All 26 were taken again with `npm run screenshots`. + +### 2026-10-23 — No glow behind the gem + +User request: the yellow circle drawn behind an uncovered gem is gone; the gem is drawn on its +own, still bobbing gently. (The glow dates from when the pickup was a power-up icon that needed +to stand out from drops; a gem is distinct enough.) + +### 2026-10-23 — Developer mode is only the gem + +User request: Developer mode no longer gives every ability and pass, 5 bombs, blast 5 and +unlimited charges; it **only** puts the level's gem uncovered right of the start (see "Developer +mode starts each level next to the gem"). Gone with it: `DEV_STATS`, `DEV_CHARGES`, charges never +being spent, and the ∞ badges and "(unlimited)" labels. Its description in the New run dialog, +the top bar's mode tag and the docs say so. The `move-picked` screenshot, which used Developer +mode to show every Move upgrade, now shows a plain Move. Open question 5 of PLAN.md §5b (Developer +mode bypassing slots) no longer applies. `SIM_VERSION` 10, since a run saved with the option on +would replay differently. + +### 2026-10-23 — Go keeps the walk icon + +User request: while walking to a destination, the Move button (Go) no longer turns into a green +flag; it keeps Move's walk icon. It still gets its green border, so it shows that a press walks +a step, and its name for screen readers and the tooltip is still "Go". `flag_green_a` is now only +the level's icon (top bar, level-cleared banner). + +### 2026-10-23 — Charges work on pickup, as temporary changes + +User request: charges are no longer actions but temporary changes, applied the moment they are +picked up. The new set and icons are the user's; the open points were the user's choices too: + +| Charge | Effect | Icon | +|---|---|---| +| Pierce | next 3 bombs' flame goes through crates | Wall Pass icon | +| Power | next 3 bombs reach to the walls | Fire+ icon | +| Nuke (new) | next 3 bombs also burn every tile within 2 steps (Manhattan), through anything but walls | bomb + Board Game Icons skull badge | +| Diagonal | diagonal steps for the rest of this level and all of the next ("round" = level) | Move figure + diagonal-arrows badge | +| Shield | the next hit is absorbed, then it's gone | Move figure in the empty ring `timer_0` | +| Freeze | every enemy frozen for 3 turns, the pickup turn included | Game Icons `locked` | +| Decoy | a copy of the player on the pickup tile (user's choice over "next to you") | `pawns` | + +Line is gone (not in the list). Choices: 3 bombs per Pierce / Power / Nuke pickup (over 1 or 5); +Nuke reach 2 (over 3: with a fuse of 3, 2 is escapable on foot, 3 hardly without Run). + +How it works: `state.charges` is replaced by `state.boosts` (bombs left of each bomb boost, and +the last level Diagonal lasts to); Shield, Freeze and Decoy act through the existing `shield`, +`frozen` and `decoy`. `applyCharge` runs on pickup; picking up more adds to it (3 more bombs, +one more level of Diagonal). Every bomb placed or thrown uses one of each bomb boost held, so +they combine (`Bomb.pierce` / `power` / `nuke`, replacing `Bomb.special`). Boosts and an unbroken +shield go on to the next level and through a death, as charges did. The shield, freeze, decoy, +pierce, power, line actions and their buttons, keys (H, F, Y, I, O, L) and the Shield / Freeze +double tap are gone; the bar is Move, Bomb and Wait; Diagonal stays an action of Move (G, then Q +E Z C) while it lasts. The top bar shows each active charge with its count (bombs, levels or +turns left; the Shield while it's up), and tapping one explains it. Shield, Freeze and Decoy's +one-colour icons turn white there (MONO_ICONS); the Diagonal icon, like Run's, stays dark on the +bar. `?charges=N` now starts as if N of each lasting charge had been picked up (not Freeze or +Decoy, which act on the spot). The new icons are built by `tools/compose-icons.sh` from source +icons copied unchanged into `assets/kenney-board-icons/` (`skull`, `timer_0`, `pawns`, +`arrow_diagonal_cross`) and `assets/kenney-icons/White/` (`locked`). `SIM_VERSION` 11. +Screenshots: `pierce-decoy` became `nuke` (a bomb with all three badges and the Nuke's reach in +the danger overlay); `charges` shows the top bar with every lasting charge. + +### 2026-10-23 — Diagonal is a power-up; shield, auto-move, top bar, installing + +User requests, from one round of playtesting: + +- **Diagonal becomes a power-up** ("a nice walk power-up"): an ability like Run, Teleport, Kick + and Throw, kept on death, weight 8 in the gem's draw (as Kick and Throw), icon + `powerup_diagonal` (the former charge icon). It is no longer a charge: six charges remain. +- **The Shield's ring walks with the player** (it jumped from the old tile to the new one): the + Shield's and a Star's rings are drawn where the player is drawn (`playerPos`), sliding with a + step. **It is smaller**: 0.88 of a tile across (was 1.08, which spilled onto the next tiles). +- **Setting an auto-move way takes the first step at once**, as a tap next to the player does; + an unsafe first step isn't taken (it shows red, as before). "Walk there" in a dialog does the + same. +- **The board never moves when the top bar fills up.** The user is fine with the bar resizing, + but not the board moving. The bar now has a fixed number of rows, by screen width only: three + on a phone or a screen under 900 px (lives, level, enemies, coins / turn, bombs, blast, fuse / + passes, Star and charges, a row kept even while empty), one row on a wide screen. Rows never + wrap; a row that gets too long scrolls sideways. Checked in a browser at 320, 390, 700, 899 and + 1280 px wide: the bar's height is the same with nothing, with every charge, and with every mode + tag. With all four mode tags on a phone, the first row scrolls, hiding part of the hearts. +- **Installable**, as PLAN.md §17 planned (display fullscreen, icons, offline cache), but without + a PWA plugin: a manifest, icons built by `tools/compose-icons.sh` (the bomb on the board's sky + blue; a maskable one with room round it; an Apple touch icon), and a network-first service + worker (`static/sw.js`) registered only in the build, so development keeps Vite's live reload. + Checked in Chromium against the built game: no manifest or installability errors, the worker + active, and the game loads with the network cut. + +`SIM_VERSION` 12 (Diagonal moved from the charges to the powers). Also fixed: rewriting the +README's charge section earlier the same day had dropped the sections between it and "Lives" +(the preview notes, Bombs, Danger, Enemies, Drops and coins, The exit, Power-ups); they are back, +with the day's changes in them. + +### 2026-10-23 — A death keeps every power-up + +User request: power-ups stay after a death. `carryOf` now carries everything to the retry — +stats, every ability and pass (Detonator, Wall / Bomb / Flame Pass included), coins, charges' +boosts and an unbroken Shield; a death costs only the life. (A Star can't be running at a death, +since nothing hurts the player while it lasts.) The "Kept / Lost when you die" endings are gone +from the power-up texts, the README's "Lost when you die?" column and the death banner's "Lost: +…" list too. This drops the NES rule PLAN.md §5 and §9 were built on: the planned Keep / Fresh +choice on death (§9, not built) took the passes away in both options, so what Keep vs Fresh +costs needs rethinking before it is built — marked open there. §5b's question 4 (what a death +costs with slots) is settled: nothing. `SIM_VERSION` 13. + +### 2026-10-23 — Enemies spread out + +User report: enemies stacked on one tile moved in the same direction, taking up one tile +together — "not fun, they should go their separate ways". Why: enemies plan without looking at +each other and may share a tile, so enemies on one tile make the same choices (Drifters go +straight; Seekers take the same shortest path; Blobs keep their heading 3 times in 4), and the 3 +let out of a blasted exit or gem start on one tile facing the same way (and since the shelter rule +they live). + +Now, in `planIntents`, enemies are planned in turn and each keeps clear of where the ones before +it will be at each of the next 3 moves (`spreadSteps`): a Blob wanders among the clear steps, a +Drifter turns if straight on is another enemy's, a Seeker's step toward the player is dropped +for a wander if another enemy takes that tile. A crowded step is used only when no open step is +clear. Enemies still don't block each other, so each still makes exactly the moves its arrows +show. Tests: three of a kind on one tile in an open room never share a tile over their 3 +planned moves (they all fail without the rule); they still share when there is no other way. +`SIM_VERSION` 14. + +### 2026-10-23 — Guards against a frozen game, and the game's logs in the dev terminal + +User report: on a phone (under `npm run dev`), the game froze with the player missing from the +board. Why it froze rather than carrying on: LittleJS's development build stops its frame loop at +the first error in a frame (its release build goes on). + +**The cause:** a Freeze drop on the board. LittleJS cuts a 128 × 128 tile out of each board +texture; every sprite is 128 × 128 except the new Freeze icon (`charge_freeze`), inked from a +100 × 100 Game Icons file, so its tile was invalid and the development build's check threw while +drawing the drops — after the coin drawn before it, so the enemies, fading things and the player +were never drawn. Found by replaying the screenshot's situation (Small, Developer, Crumbling +crates, the last enemy blasted) on 16 seeds in a browser: seed 13 dropped a Freeze and failed +with "Sprite charge_freeze (texture 68): Assert failed". Fixed: `tools/compose-icons.sh` makes +every inked icon 128 × 128; a test (`test/sprites.test.ts`) checks every PNG in `assets/kenney` +and `assets/composed` is 128 × 128 (it fails with the old file); the same seed then played on to +turn 20, and the 16 seeds without an error. + +- **Guards** (user request): the game's per-frame update and draw are wrapped (`guarded` in + main.ts): an error is reported once while it repeats, and the next frame runs as usual. A + turn the rules refuse (`step` throws) is reported and nothing happens, instead of an uncaught + error. `sprite()` now names an unknown sprite instead of handing LittleJS texture −1. +- **Logs on the dev desktop** (user request: "a callback to show the logs as npm run dev + output"): Vite 8's own `server.forwardConsole` sends the browser's console (log, info, warn, + error) and uncaught errors over its dev connection to the terminal running `npm run dev`, + marked `(client)`, from any device on the Wi-Fi. A first version had its own plugin for this; + Vite's built-in does the same, so it was dropped. Caught errors are logged with their stack and + the device ("Android 390×844") by `src/devlog.ts`. Nothing of this is in the build. +- **The top bar's first row** was full of mode tags in that game (Small, Dev, Crumble), which + pushed the level, enemies and coins out of sight. The mode tags now start the third row, so the + first row always shows hearts, level, enemies and coins. + +### 2026-10-23 — Into the doorway, and a demo before the game + +- **The step into the exit is animated** (user report: it happened instantly). The turn reports + the step and then the level cleared; the cleared jump replaced the step's walk at once. The + jump now remembers where the step began: the player walks into the doorway (0.16 s), then jumps. +- **A demo before the game** (user request: some people never played the original; scenes + generated at play time and shown view-only, like a screensaver, before the player starts). + Made up on the spot from the real rules rather than recorded: a small random room, random + games on it until one shows the scene (an enemy killed, a narrow escape, a chain reaction, + Freeze, the Detonator), replayed with a caption. Details in PLAN.md §13a. Choices made here, + since the request left them open: it plays **every time the game opens** (not only the first + time), until any tap or key, which then does nothing else (a click on a button that appears + under the finger is swallowed too); the scenes cycle through all five kinds in a shuffled + order; captions are one plain sentence each; test URLs skip it unless `?demo`, and with `?seed` + it is the same every time (for the screenshot). The rooms are 9 × 7, so on a tall phone the + room sits in the middle with sky above and below. Tests: every kind is found on 10 seeds, + replays by the rules with the player alive throughout, and ends on its moment; the same seed + gives the same scene. Checked in a browser: scenes play with captions and no errors; a tap + ends the demo and the game starts at turn 0 with nothing else done. +- LittleJS's development build draws its version and frame rate in the top left; the top bar + covered it, the demo doesn't, so it is turned off (`setDebugWatermark(false)`). + +### 2026-10-23 — The last enemy drops a charge that lasts; icons in the demo's captions + +- **No wasted charges from the last enemy** (user report: a charge dropped by the last enemy, + like a Decoy, was often wasted). Freeze and Decoy only work on enemies. Of three options (the + last enemy drops only lasting charges; Freeze and Decoy picked up with no enemies are saved for + the next level; both), the user chose the first: when a turn's kills leave no enemy on the + board, every charge they drop is drawn from Pierce, Power, Nuke and Shield, which go on to the + next level. Decided once the turn's kills are all done, so two enemies dying together are both + "last". A Freeze or Decoy dropped earlier and picked up after the last kill still does nothing + (accepted). Tests: over 2,000 rolls a last drop is never Freeze or Decoy (and all four lasting + kinds come up), an ordinary drop still can be. `SIM_VERSION` 15. +- **The demo's captions show the icon** of the charge or power-up a scene is about (user + request: "better visual associations with concepts"): the padlock for Freeze, the Detonator's + badged bomb for the Detonator — the same icon as on the board, in the top bar and the almanac. + The three scenes about bombs in general have none. The icon sits beside the words, not in the + line, so a two-line caption stays evenly spaced. diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png index 259e715..196e99b 100644 Binary files a/docs/screenshots/almanac-charges.png and b/docs/screenshots/almanac-charges.png differ diff --git a/docs/screenshots/almanac-enemies.png b/docs/screenshots/almanac-enemies.png index 8a43a8d..da9732b 100644 Binary files a/docs/screenshots/almanac-enemies.png and b/docs/screenshots/almanac-enemies.png differ diff --git a/docs/screenshots/almanac.png b/docs/screenshots/almanac.png index 4cbebfc..eee4858 100644 Binary files a/docs/screenshots/almanac.png and b/docs/screenshots/almanac.png differ diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png index 046f933..37829e5 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 c6fa923..8146a75 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 5363982..3d2252e 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 0a211cb..0aabfe6 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 0506ab4..0d4e780 100644 Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ diff --git a/docs/screenshots/demo.png b/docs/screenshots/demo.png new file mode 100644 index 0000000..0c4ee20 Binary files /dev/null and b/docs/screenshots/demo.png differ diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png index f653240..83428b3 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 f1185a1..abff8e7 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 86100df..b6eadfa 100644 Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ diff --git a/docs/screenshots/gem-offer.png b/docs/screenshots/gem-offer.png new file mode 100644 index 0000000..685532d Binary files /dev/null and b/docs/screenshots/gem-offer.png differ diff --git a/docs/screenshots/gem.png b/docs/screenshots/gem.png new file mode 100644 index 0000000..5d3c9e6 Binary files /dev/null and b/docs/screenshots/gem.png differ diff --git a/docs/screenshots/info-enemy.png b/docs/screenshots/info-enemy.png index e982d0d..0a7fb44 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 c65e6df..2ac3c62 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 eda83be..3ba678f 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 0da84a6..c1a0e4b 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 ac85f03..174ec4e 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 bfa645a..008af24 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 index c7f629d..f26d04a 100644 Binary files a/docs/screenshots/move-picked.png and b/docs/screenshots/move-picked.png differ diff --git a/docs/screenshots/new-run.png b/docs/screenshots/new-run.png index 5c84371..d303cff 100644 Binary files a/docs/screenshots/new-run.png and b/docs/screenshots/new-run.png differ diff --git a/docs/screenshots/nuke.png b/docs/screenshots/nuke.png new file mode 100644 index 0000000..8beb80e Binary files /dev/null and b/docs/screenshots/nuke.png differ diff --git a/docs/screenshots/pierce-decoy.png b/docs/screenshots/pierce-decoy.png deleted file mode 100644 index 4034fed..0000000 Binary files a/docs/screenshots/pierce-decoy.png and /dev/null differ diff --git a/docs/screenshots/power-up.png b/docs/screenshots/power-up.png deleted file mode 100644 index c5fcb5c..0000000 Binary files a/docs/screenshots/power-up.png and /dev/null differ diff --git a/docs/screenshots/run.png b/docs/screenshots/run.png deleted file mode 100644 index f67fa56..0000000 Binary files a/docs/screenshots/run.png and /dev/null differ diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png index 41d47cc..6b1ede5 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 85c3b91..35e6590 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 f5ca200..6ae57e2 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 c53bfc7..03d1e8f 100644 Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ diff --git a/index.html b/index.html index 50465d5..645a737 100644 --- a/index.html +++ b/index.html @@ -9,18 +9,28 @@ <link rel="icon" type="image/png" href="/assets/kenney/bomb.png" /> <meta name="mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" /> +<meta name="apple-mobile-web-app-title" content="Explodorama" /> +<link rel="manifest" href="/manifest.webmanifest" /> +<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" /> <link rel="stylesheet" href="/src/style.css" /> </head> <body> <header id="hud" aria-live="polite"> <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 class="hud-row"> + <span id="hud-lives"></span> + <span id="hud-level"></span> + <span id="hud-enemies"></span> + <span id="hud-coins"></span> + </div> + <div class="hud-row"> + <span id="hud-turn"></span> + <span id="hud-stats"></span> + </div> + <div class="hud-row" id="hud-third"> + <span id="hud-modes"></span> + <span id="hud-extras"></span> + </div> </div> <div id="hud-buttons"> <button id="info-button" type="button" aria-label="What is that?" aria-pressed="false"></button> @@ -42,6 +52,11 @@ <div id="dialog-buttons"></div> </div> </dialog> +<div id="demo" hidden aria-live="polite"> + <h1>Explodorama</h1> + <p id="demo-caption"></p> + <p class="demo-hint">Tap anywhere to play</p> +</div> <script type="module" src="/src/main.ts"></script> </body> </html> diff --git a/package.json b/package.json index a5c51ae..1c18299 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "vite --host", "build": "tsc --noEmit && vite build", - "preview": "vite preview", + "preview": "vite preview --host", "test": "vitest run", "test:watch": "vitest", "screenshots": "npm run build && node tools/screenshots.mjs" diff --git a/src/devlog.ts b/src/devlog.ts new file mode 100644 index 0000000..e7c4490 --- /dev/null +++ b/src/devlog.ts @@ -0,0 +1,18 @@ +/** + * Errors the game catches and carries on from (`guarded` frames, refused turns). They go to the + * console with their stack and the device, and `npm run dev` forwards the console to its terminal + * (`server.forwardConsole` in vite.config.ts), from a phone on the Wi-Fi too (user request, + * 2026-10-23: a crash on the phone showed nothing anywhere). + */ + +/** Which device it came from, in a few words: "Android 390×844", "iPhone 390×844", "desktop 1280×720". */ +function device(): string { + const ua = navigator.userAgent + const kind = /iPad/.test(ua) ? 'iPad' : /iPhone/.test(ua) ? 'iPhone' : /Android/.test(ua) ? 'Android' : 'desktop' + return `${kind} ${innerWidth}×${innerHeight}` +} + +export function reportError(where: string, error: unknown): void { + const detail = error instanceof Error ? (error.stack ?? `${error.name}: ${error.message}`) : String(error) + console.error(`[${device()}] ${where}: ${detail}`) +} diff --git a/src/main.ts b/src/main.ts index cd7a390..b5eeb8b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import { screenToWorld, setCanvasClearColor, setCanvasPixelRatio, + setDebugWatermark, setGLEnable, setShowSplashScreen, setTilesPixelated, @@ -13,7 +14,7 @@ import { import { BACKGROUND, drawBoard, FLAME_SECONDS, toTile, type Flame, type PlayerAnim } from './render/board' import { clearEffects, newEffects, pruneEffects, startEffects } from './render/effects' import { updateCamera } from './render/camera' -import { TEXTURES } from './render/sprites' +import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, TEXTURES } from './render/sprites' import { isAvailable, isValidTarget, ACTIONS } from './sim/actions' import { canPick, resolvePick } from './sim/picks' import { enemyAt } from './sim/enemies' @@ -26,7 +27,10 @@ import { DEFAULT_OPTIONS, DIRS, type Action, type ActionId, type ChargeId, type import { openAlmanac } from './ui/almanac' import { createControls } from './ui/controls' import { dialogOpen, openDialog, type DialogButton } from './ui/dialog' -import { actionInfo, chargeInfo, hudInfo, modeLines, powerUpInfo, tileInfo, type Info } from './ui/info' +import { actionInfo, chargeInfo, hudInfo, modeLines, tileInfo, type Info } from './ui/info' +import { openOffer } from './ui/offer' +import { reportError } from './devlog' +import { generateScene, SCENE_CAPTIONS, SCENE_KINDS, type Scene, type SceneKind } from './sim/demo' 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' @@ -35,7 +39,7 @@ import newRunIcon from '../assets/kenney-ui/Extra/icon_repeat_light.png' // seed — chosen here, never inside the sim. const params = new URLSearchParams(location.search) const startLevel = Number(params.get('level')) || 1 -// `?charges=2` starts with 2 of every action charge, to try them without hunting for drops. +// `?charges=2` starts as if 2 of each lasting charge had been picked up (state.ts `newRun`), to try them. const startCharges = Number(params.get('charges')) || 0 let record: RunRecord @@ -72,8 +76,8 @@ function firstCharge(e: GameEvent): ChargeId | null { return e.drop.kind } -function startRun(seed: number, options: RunOptions): void { - chargesSeen.clear() +/** A fresh view of the board: no animations under way, nothing picked, the camera snapped. */ +function resetView(): void { playerAnim = { kind: 'none', from: { x: 0, y: 0 }, at: 0 } enemySteps.clear() clearEffects(fx) @@ -82,6 +86,11 @@ function startRun(seed: number, options: RunOptions): void { destination = null flames = [] snapCamera = true +} + +function startRun(seed: number, options: RunOptions): void { + chargesSeen.clear() + resetView() record = newRecord(seed, startLevel, startCharges, options) state = newRun(seed, startLevel, startCharges, options) save() @@ -142,6 +151,8 @@ function resumeSaved(): boolean { record = saved state = s afterChange() + // Closed with a gem's choice still open: it opens again. + showOffer() return true } catch { return false @@ -156,12 +167,38 @@ function randomSeed(): number { const MOVES: ReadonlySet<Action['id']> = new Set(['move', 'run', 'diagonal', 'teleport']) function act(action: Action): void { + // A gem's offer is answered before anything else (its dialog opens a moment after the pickup). + if (state.offer && action.id !== 'choose') return lastActionAt = time if (MOVES.has(action.id) && 'target' in action && !path?.some((t) => eq(t, action.target!))) destination = null - const result = step(state, action) + // step() throws on an action the rules don't allow; then nothing happens, and it is reported. + let result: ReturnType<typeof step> + try { + result = step(state, action) + } catch (error) { + reportError(`turn ${state.turn}, ${JSON.stringify(action)}`, error) + return + } + animateTurn(result, action) + record.actions.push(action) + for (const e of result.events) { + // A gem taken: its choice opens once the gem has risen away (render/effects.ts). + if (e.type === 'powerUpTaken') window.setTimeout(showOffer, PICKUP_DIALOG_MS) + // The first charge of each kind this run says what it does, so a new button is never a + // mystery; unless a gem was taken on the same turn (a run over both), whose choice comes first. + const charge = firstCharge(e) + if (charge) window.setTimeout(() => state.offer || showInfo(chargeInfo(charge)), PICKUP_DIALOG_MS) + } + selected = null + inspecting = false + save() + afterChange() +} + +/** A turn played: the new state, and everything that animates it (also for the demo). */ +function animateTurn(result: ReturnType<typeof step>, action: Action): void { startEffects(fx, state, result.state, result.events, time) state = result.state - record.actions.push(action) for (const e of result.events) { if (e.type === 'exploded') flames.push({ tiles: e.tiles, born: time }) if (e.type === 'levelRestarted' || e.type === 'levelStarted') snapCamera = true @@ -173,30 +210,25 @@ function act(action: Action): void { playerAnim = { kind: far ? 'teleport' : 'walk', from: e.from, at: time } } if (e.type === 'bombPlaced' && playerAnim.kind !== 'walk') playerAnim = { kind: 'duck', from: state.player, at: time } - if (e.type === 'levelCleared') playerAnim = { kind: 'jump', from: state.player, at: time } + // Into the doorway first (the step's walk), then the jump (user request, 2026-10-23: the step + // onto the exit had no animation). + if (e.type === 'levelCleared') playerAnim = { kind: 'jump', from: playerAnim.kind === 'walk' ? playerAnim.from : state.player, at: time } if (e.type === 'levelStarted' || e.type === 'levelRestarted') { playerAnim = { kind: 'none', from: state.player, at: time } 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)), PICKUP_DIALOG_MS) - } - // The same for the first charge of each kind this run, so a new button is never a mystery. - const charge = firstCharge(e) - if (charge) window.setTimeout(() => showInfo(chargeInfo(charge)), PICKUP_DIALOG_MS) } - selected = null - inspecting = false - save() - afterChange() } -/** How long after taking a power-up (or a new charge) its dialog opens: the walk there and the rise away first. */ +/** How long after taking a gem (or a new charge) its dialog opens: the walk there and the rise away first. */ const PICKUP_DIALOG_MS = 450 +/** The gem's choice, if one is waiting. Choosing takes no turn. */ +function showOffer(): void { + if (state.offer) openOffer(state.offer, (kind) => act({ id: 'choose', kind })) +} + function afterChange(): void { danger = predictDanger(state) // The walk is worked out again every turn, since bombs and crates change; reaching the @@ -213,7 +245,7 @@ function afterChange(): void { * - the player: Wait; * - the auto-move flag: one step toward it (like Go), with auto-move's safety check; * - a tile next to the player: move there; - * - any other tile you can walk to: set it as the auto-move destination; + * - any other tile you can walk to: set it as the auto-move destination, and take the first step; * - a wall or crate, or any floor you can't walk to: clears the flag if there is one; * - 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); @@ -255,9 +287,14 @@ function inspect(): void { afterChange() } +/** + * Auto-move to `p`, taking the first step at once, as a tap next to the player would (user + * request, 2026-10-23: setting the way used to wait for Go). An unsafe first step isn't taken. + */ function setDestination(p: Vec): void { destination = p afterChange() + stepTowardDestination() } /** The info dialog for a tile; with "Walk there" when it is something to walk to. */ @@ -314,7 +351,7 @@ function newRunDialog(): void { `<label><input type="radio" name="size" value="normal"${checked(o.size === 'normal')}><span><b>Normal</b> 31 × 13, scrolls sideways, 50 levels.</span></label>` + `<label><input type="radio" name="size" value="small"${checked(o.size === 'small')}><span><b>Small</b> 9 × 11, fits the screen, 20 levels, fewer enemies and crates.</span></label>` + `</fieldset><fieldset class="choices"><legend>Modes</legend>` + - `<label><input type="checkbox" name="dev"${checked(o.dev)}><span><b>Developer</b> Every power-up and ability, unlimited action charges, 5 bombs, blast 5. You can still be killed.</span></label>` + + `<label><input type="checkbox" name="dev"${checked(o.dev)}><span><b>Developer</b> Every level’s gem lies uncovered right next to you at the start.</span></label>` + `<label><input type="checkbox" name="god"${checked(o.god)}><span><b>God</b> Nothing can kill you.</span></label>` + `<label><input type="checkbox" name="crumble"${checked(o.crumble)}><span><b>Crumbling crates</b> Once the last enemy is gone, the crates crumble away over 1–3 turns.</span></label>` + `</fieldset>`, @@ -385,8 +422,7 @@ function pick(id: ActionId, pressed = selected === id): void { } if (!canPick(state, id)) return if (id === 'move' && destination && !selected && stepTowardDestination()) return - // 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). + // Wait happens at once. Everything else is picked first, then aimed at a tile. if (id === 'wait') act({ id }) else { selected = pressed ? null : id @@ -394,14 +430,6 @@ function pick(id: ActionId, pressed = selected === id): void { } } -/** 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. @@ -424,7 +452,6 @@ 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, @@ -438,8 +465,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', - KeyG: 'diagonal', KeyP: 'teleport', KeyI: 'pierce', KeyO: 'power', KeyL: 'line', KeyY: 'decoy', + KeyB: 'bomb', KeyR: 'run', KeyX: 'detonate', KeyK: 'kick', KeyT: 'throw', KeyG: 'diagonal', KeyP: 'teleport', } /** With Diagonal picked: Q / E / Z / C step up-left, up-right, down-left, down-right. */ @@ -467,9 +493,8 @@ window.addEventListener('keydown', (e) => { } afterChange() } 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() + // Presses the banner's button. + controls.pressBanner() } }) @@ -493,6 +518,121 @@ function tileUnder(e: PointerEvent): Vec | null { if (!resumeSaved()) startRun(Number(params.get('seed')) || randomSeed(), testing ? urlOptions() : preferredOptions()) +/** + * The demo, before the game starts (user request, 2026-10-23: some players never played the + * original). Scenes made up on the spot (sim/demo.ts) are replayed view-only, one turn every + * DEMO_STEP_SECONDS, each with a caption, the top bar and buttons hidden; the run waits behind it. + * Any tap or key ends it, and does nothing else. Not on a test URL, unless it has `?demo`. + */ +const DEMO_STEP_SECONDS = 0.6 +/** How long a scene's last turn stays on screen, and how long before a scene's first move. */ +const DEMO_HOLD_SECONDS = 1.8 +const DEMO_LEAD_SECONDS = 0.8 +let demo: { scene: Scene; next: number; at: number; real: GameState } | null = null +// The scenes' seeds: from `?seed` for a repeatable demo, otherwise random. +let demoSeed = Number(params.get('seed')) || randomSeed() +let demoKinds: SceneKind[] = [] +const demoPanel = document.getElementById('demo')! +const demoCaption = document.getElementById('demo-caption')! +/** + * The icon beside the caption of a scene about one charge or power-up, the one it has on the board, + * in the top bar and in the almanac (user request, 2026-10-23), so the picture and the idea go + * together. + */ +const SCENE_ICONS: Partial<Record<SceneKind, string>> = { freeze: CHARGE_ICONS.freeze, detonate: POWER_UP_ICONS.detonator } + +/** The next scene, cycling through every kind in a shuffled order. */ +function nextScene(): void { + if (!demo) return + for (let tries = 0; tries < SCENE_KINDS.length; tries++) { + // Shuffled; in their usual order with `?seed`, so a test URL shows the same demo every time. + if (!demoKinds.length) demoKinds = params.has('seed') ? [...SCENE_KINDS] : [...SCENE_KINDS].sort(() => Math.random() - 0.5) + const scene = generateScene(demoKinds.shift()!, demoSeed++) + if (!scene) continue + resetView() + demo.scene = scene + demo.next = 0 + demo.at = time + DEMO_LEAD_SECONDS + state = scene.start + const icon = SCENE_ICONS[scene.kind] + demoCaption.innerHTML = + (icon ? `<img class="demo-icon${MONO_ICONS.has(icon) ? ' mono' : ''}" src="${spriteUrl(icon)}" alt="" draggable="false">` : '') + + `<span>${SCENE_CAPTIONS[scene.kind]}</span>` + afterChange() + return + } +} + +function startDemo(): void { + demo = { scene: null!, next: 0, at: 0, real: state } + document.body.classList.add('demo') + demoPanel.hidden = false + nextScene() + if (!demo.scene) endDemo() +} + +/** Called every frame: the scene's next turn when it is due, or the next scene. */ +function demoTick(): void { + if (!demo || time < demo.at) return + const { scene } = demo + if (demo.next >= scene.actions.length) return nextScene() + const action = scene.actions[demo.next++]! + animateTurn(step(state, action), action) + afterChange() + demo.at = time + (demo.next < scene.actions.length ? DEMO_STEP_SECONDS : DEMO_HOLD_SECONDS) +} + +function endDemo(): void { + if (!demo) return + state = demo.real + demo = null + document.body.classList.remove('demo') + demoPanel.hidden = true + resetView() + afterChange() +} + +// The tap or key that ends the demo goes no further: not to the board, nor to a button that +// appears under the finger once the demo is gone (its click is swallowed too). +let swallowClick = 0 +addEventListener( + 'pointerdown', + (e) => { + if (!demo) return + endDemo() + swallowClick = performance.now() + 600 + e.stopPropagation() + e.preventDefault() + }, + true, +) +for (const type of ['pointerup', 'click'] as const) { + addEventListener( + type, + (e) => { + if (performance.now() > swallowClick) return + e.stopPropagation() + e.preventDefault() + }, + true, + ) +} +addEventListener( + 'keydown', + (e) => { + if (!demo) return + endDemo() + e.stopPropagation() + e.preventDefault() + }, + true, +) + +if (!testing || params.has('demo')) startDemo() + +// LittleJS's development build writes its version and frame rate top left; the top bar used to +// cover it, but the demo hides the bar (only the development build has it at all). +setDebugWatermark(false) // Sharp on high-density screens, capped so phones don't pay for 3×. setCanvasPixelRatio(Math.min(devicePixelRatio, 2)) // Canvas 2D, not WebGL: a board of a few hundred sprites doesn't need it, and headless @@ -502,6 +642,24 @@ setGLEnable(false) // The sprites are smooth vector-style art drawn smaller than their 128 px, not pixel art. setTilesPixelated(false) +/** + * A frame's work, made safe: an error is reported (once while it repeats) and the game goes on. + * LittleJS's development build stops its loop at the first error, which froze the game on a + * phone with the player half-drawn (user report, 2026-10-23). + */ +function guarded(where: string, frame: () => void): () => void { + let last = '' + return () => { + try { + frame() + last = '' + } catch (error) { + if (String(error) !== last) reportError(where, error) + last = String(error) + } + } +} + await engineInit( () => { setShowSplashScreen(false) @@ -534,19 +692,30 @@ await engineInit( // A long press shouldn't open the browser's context menu. mainCanvas.addEventListener('contextmenu', (e) => e.preventDefault()) }, - () => { - updateCamera(state, controls.insets(), snapCamera) + guarded('update', () => { + demoTick() + // In the demo the board fits between the top of the screen and its caption panel. + updateCamera(state, demo ? { top: 0, bottom: demoPanel.getBoundingClientRect().height } : controls.insets(), snapCamera) snapCamera = false flames = flames.filter((f) => time - f.born < FLAME_SECONDS) pruneEffects(fx, time) autoStep() - }, + }), () => {}, - () => + guarded('draw', () => drawBoard({ state, danger, selected, hover, flames, facingLeft, destination, path, armed, playerAnim, enemySteps, fx, pathBlocked: !!path?.[0] && !safeStep(path[0]), }), + ), () => {}, TEXTURES, ) + +// Installable and playable offline (static/sw.js). Only in the built game: in development it +// would serve stale copies over Vite's live reloading. +if (import.meta.env.PROD && 'serviceWorker' in navigator) { + navigator.serviceWorker.register('./sw.js').catch(() => { + // No service worker (a private window, an old browser): the game still runs, just not offline. + }) +} diff --git a/src/render/board.ts b/src/render/board.ts index fabb6e3..039d6a8 100644 --- a/src/render/board.ts +++ b/src/render/board.ts @@ -7,7 +7,7 @@ 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, 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' +import { CHARGE_ICONS, digitSprite, ENEMY_SPRITES, GEM_SPRITES, sprite, wallSprite, type BoardSprite } from './sprites' /** * The one place where sim tiles meet LittleJS world space. The sim's y grows downward @@ -86,9 +86,7 @@ const C = { intent: rgb(0.85, 0.2, 0.2), shield: rgb(0.25, 0.62, 0.87, 0.9), clear: rgb(0, 0, 0, 0), - pathDot: rgb(0.25, 0.62, 0.87, 0.7), burning: rgb(1, 0.55, 0.1, 0.5), - powerUpGlow: rgb(1, 0.95, 0.55, 0.8), 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), @@ -147,12 +145,11 @@ export function drawBoard(view: View): void { if (cellAt(s, s.exit) === 'floor') { drawExit(toWorld(s, s.exit), exitOpen(s), appearScale(fx, key.exit, time) * pulseScale(fx, key.exit, time, 0.25)) } - // The level's power-up, once its crate is gone: on a soft glow, so it stands out from drops. + // The level's gem, once its crate is gone, bobbing gently (no glow behind it: user request, 2026-10-23). if (s.powerUp && cellAt(s, s.powerUp.pos) === 'floor') { const pos = toWorld(s, s.powerUp.pos) const k = appearScale(fx, key.powerUp, time) - drawCircle(pos, 0.86 * k, C.powerUpGlow) - drawTile(pos.add(vec2(0, 0.04 * Math.sin(time * 3))), vec2(0.7 * k), sprite(POWER_UP_ICONS[s.powerUp.kind])) + drawTile(pos.add(vec2(0, 0.04 * Math.sin(time * 3))), vec2(0.7 * k), sprite(GEM_SPRITES[s.powerUp.color])) } // A destroyed crate bursts over whatever it uncovered. for (const g of fx.gone) if (g.kind === 'crate') drawGone(s, g) @@ -164,12 +161,10 @@ export function drawBoard(view: View): void { 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) + // Auto-move's way: every tile of it highlighted blue, the destination too, with no flag or dots + // (user request, 2026-10-23). The next step is red when it isn't safe to take. 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) - else drawCircle(toWorld(s, t), 0.16, C.pathDot) - } - drawTile(toWorld(s, view.destination), vec2(0.8), sprite('flag_green_a')) + for (const [i, t] of view.path.entries()) drawRect(toWorld(s, t), vec2(0.94), i === 0 && view.pathBlocked ? C.blocked : C.target) } // The commit preview: the picked action played on a copy, then whatever it changed drawn faded. const after = view.armed @@ -208,9 +203,9 @@ export function drawBoard(view: View): void { const k = key.bomb(b.pos) 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)) + drawBomb(pos, b.timer, hot ? 'bomb_active' : 'bomb', C.white, b, 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) + for (const b of changedBombs) drawBomb(toWorld(s, b.pos), b.timer, 'bomb', C.ghost, b) if (s.decoy) drawDecoy(toWorld(s, s.decoy.pos), C.white, appearScale(fx, key.decoy, time)) if (after?.decoy && !s.decoy) drawDecoy(toWorld(s, after.decoy.pos), C.ghost) @@ -223,15 +218,27 @@ export function drawBoard(view: View): void { for (const e of s.enemies) drawEnemy(s, e, view.enemySteps.get(e.id), fx) for (const g of fx.gone) if (g.kind !== 'crate' && g.kind !== 'flame') drawGone(s, g) - if (s.shield) drawCircle(toWorld(s, s.player), 1.08 * appearScale(fx, key.shield, time), C.clear, 0.07, C.shield) - // A Star: a pulsing gold ring for as long as it lasts. - if (s.star > 0) drawCircle(toWorld(s, s.player), 1.0 + 0.06 * Math.sin(time * 8), C.clear, 0.08, C.star) + // The Shield's ring and a Star's pulsing gold ring go where the player is drawn, so they walk + // with them (user request, 2026-10-23: they jumped to the new tile). The Shield's stays inside + // the player's tile. + const at = playerPos(s, view.playerAnim) + if (s.shield) drawCircle(at, 0.88 * appearScale(fx, key.shield, time), C.clear, 0.06, C.shield) + if (s.star > 0) drawCircle(at, 1.0 + 0.06 * Math.sin(time * 8), C.clear, 0.08, C.star) if (after && !eq(after.player, s.player)) { drawTile(toWorld(s, after.player), tileSize, sprite('character_green_idle'), C.ghost, 0, view.facingLeft) } drawPlayer(s, view.playerAnim, view.facingLeft) } +/** Where the player is on screen right now: sliding between tiles while walking, otherwise on their tile. */ +function playerPos(s: GameState, anim: PlayerAnim): Vector2 { + const to = toWorld(s, s.player) + const t = time - anim.at + if ((anim.kind === 'walk' || anim.kind === 'jump') && t < WALK_SECONDS) return toWorld(s, anim.from).lerp(to, t / WALK_SECONDS) + if (anim.kind === 'teleport' && t < TELEPORT_SECONDS) return toWorld(s, anim.from) + return to +} + /** * The player, animated like the enemies: an idle bob with a glance at the camera now and then, * walking frames while sliding into a new tile, a crouch when placing a bomb, a jump when the @@ -251,10 +258,11 @@ function drawPlayer(s: GameState, anim: PlayerAnim, facingLeft: boolean): void { const k = out ? 1 - t / TELEPORT_SECONDS : (t - TELEPORT_SECONDS) / TELEPORT_SECONDS drawTile(out ? toWorld(s, anim.from) : to, vec2(k), sprite('character_green_front'), C.white, (1 - k) * 2, facingLeft) return - } else if (anim.kind === 'jump') { + } else if (anim.kind === 'jump' && (t >= WALK_SECONDS || eq(anim.from, s.player))) { + // Cleared: jumping for joy, once the step into the doorway (below) is done. frame = 'character_green_jump' - pos = to.add(vec2(0, 0.18 * Math.abs(Math.sin(time * 6)))) - } else if (anim.kind === 'walk' && t < WALK_SECONDS) { + pos = to.add(vec2(0, 0.18 * Math.abs(Math.sin((t - WALK_SECONDS) * 6)))) + } else if ((anim.kind === 'walk' || anim.kind === 'jump') && t < WALK_SECONDS) { const k = t / WALK_SECONDS const from = toWorld(s, anim.from) pos = from.lerp(to, k).add(vec2(0, 0.08 * Math.sin(k * Math.PI))) @@ -269,11 +277,9 @@ function drawPlayer(s: GameState, anim: PlayerAnim, facingLeft: boolean): void { drawTile(pos, vec2(1), sprite(frame), C.white, 0, facingLeft) } -/** The pack's door is two tiles tall; it is drawn narrower so both halves fit about one tile. */ +/** The exit: only the door's arched top piece, a short doorway that fills its tile (user request, 2026-10-23). */ function drawExit(pos: Vector2, open: boolean, scale = 1): void { - const half = vec2(0.56 * scale) - drawTile(pos.add(vec2(0, -0.22 * scale)), half, sprite(open ? 'door_open' : 'door_closed')) - drawTile(pos.add(vec2(0, 0.34 * scale)), half, sprite(open ? 'door_open_top' : 'door_closed_top')) + drawTile(pos, vec2(scale), sprite(open ? 'door_open_top' : 'door_closed_top')) } /** Where a bomb is drawn: on its tile, or on its way there (kicked: sliding; thrown: in an arc). */ @@ -407,16 +413,18 @@ function drawDecoy(pos: Vector2, color: Color, scale = 1): void { /** * The bomb sprite with its timer on the body, which sits a little below and right of the - * tile's centre. A Pierce or Power bomb wears its charge's icon as a badge at the top left. + * tile's centre. A Pierce, Power or Nuke bomb wears that charge's icon as a small badge along + * the top, one for each it has. */ -function drawBomb(pos: Vector2, timer: number, body: BoardSprite, color: Color, special?: Bomb['special'], scale = 1): void { +function drawBomb(pos: Vector2, timer: number, body: BoardSprite, color: Color, kinds: Pick<Bomb, 'pierce' | 'power' | 'nuke'>, scale = 1): void { if (scale <= 0) return drawTile(pos, vec2(0.9 * scale), sprite(body), color) const digits = String(timer) const w = 0.32 * scale const start = pos.add(vec2(0.04 * scale - ((digits.length - 1) * w) / 2, -0.06 * scale)) for (let i = 0; i < digits.length; i++) drawTile(start.add(vec2(i * w, 0)), vec2(0.42 * scale), digitSprite(Number(digits[i])), color) - if (special) drawTile(pos.add(vec2(-0.28, 0.28).scale(scale)), vec2(0.4 * scale), sprite(CHARGE_ICONS[special]), color) + const badges = (['pierce', 'power', 'nuke'] as const).filter((k) => kinds[k]) + badges.forEach((k, i) => drawTile(pos.add(vec2(-0.3 + 0.3 * i, 0.32).scale(scale)), vec2(0.36 * scale), sprite(CHARGE_ICONS[k]), color)) } function tileOf(s: GameState, i: number): Vec { diff --git a/src/render/effects.ts b/src/render/effects.ts index bb4c610..e089d9b 100644 --- a/src/render/effects.ts +++ b/src/render/effects.ts @@ -1,4 +1,4 @@ -import { CHARGE_ICONS, coinSprite, POWER_UP_ICONS, type BoardSprite } from './sprites' +import { CHARGE_ICONS, coinSprite, GEM_SPRITES, type BoardSprite } from './sprites' import { FREEZE_TURNS } from '../sim/actions' import type { Drop, Enemy, GameEvent, GameState, Vec } from '../sim/types' @@ -131,6 +131,8 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even break case 'pickedUp': fx.gone.push({ kind: 'taken', sprite: dropSprite(e.drop), pos: e.drop.pos, size: 0.62, at: now + TAKE_DELAY }) + // A Shield works at once: its ring grows round the player. + if (e.drop.kind === 'shield') fx.appeared.set(key.shield, now) break case 'dropBurned': fx.gone.push({ kind: 'burned', sprite: dropSprite(e.drop), pos: e.drop.pos, size: 0.62, at: now }) @@ -139,10 +141,10 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even fx.appeared.set(key.powerUp, now) break case 'powerUpTaken': - fx.gone.push({ kind: 'taken', sprite: POWER_UP_ICONS[e.powerUp.kind], pos: e.powerUp.pos, size: 0.7, at: now + TAKE_DELAY }) + fx.gone.push({ kind: 'taken', sprite: GEM_SPRITES[e.powerUp.color], pos: e.powerUp.pos, size: 0.7, at: now + TAKE_DELAY }) break case 'powerUpBurned': - fx.gone.push({ kind: 'burned', sprite: POWER_UP_ICONS[e.powerUp.kind], pos: e.powerUp.pos, size: 0.7, at: now }) + fx.gone.push({ kind: 'burned', sprite: GEM_SPRITES[e.powerUp.color], pos: e.powerUp.pos, size: 0.7, at: now }) break case 'exitRevealed': fx.appeared.set(key.exit, now) @@ -159,9 +161,6 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even case 'shieldBroke': fx.gone.push({ kind: 'shield', pos: e.pos, at: now }) break - case 'chargeUsed': - if (e.id === 'shield') fx.appeared.set(key.shield, now) - break } } // Freezing over, or thawing a step. diff --git a/src/render/sprites.ts b/src/render/sprites.ts index 8da40fe..667a693 100644 --- a/src/render/sprites.ts +++ b/src/render/sprites.ts @@ -1,5 +1,5 @@ import { tile, vec2, type TileInfo } from 'littlejsengine' -import type { ChargeId, EnemyKind, PowerUpKind } from '../sim/types' +import type { ChargeId, EnemyKind, GemColor, PowerUpKind } from '../sim/types' /** * Art from Kenney's New Platformer Pack 1.1 (CC0), copied into `assets/kenney/` under the pack's @@ -23,7 +23,7 @@ export function spriteUrl(name: string): string { * 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']) +export const MONO_ICONS: ReadonlySet<string> = new Set(['icon_move', 'icon_wait', 'icon_fuse', 'charge_shield', 'charge_freeze', 'charge_decoy']) /** The sprites drawn on the canvas. Their order is their LittleJS texture index. */ const BOARD = [ @@ -54,20 +54,11 @@ const BOARD = [ 'hud_character_8', 'hud_character_9', 'flag_green_a', - 'door_closed', 'door_closed_top', - 'door_open', 'door_open_top', 'coin_bronze', 'coin_silver', 'coin_gold', - 'switch_blue', - 'chain', - 'ramp', - 'spikes', - 'weight', - 'bridge_logs', - 'hud_player_helmet_green', 'character_green_front', 'character_green_walk_a', 'character_green_walk_b', @@ -97,6 +88,15 @@ const BOARD = [ 'powerup_wall_pass', 'powerup_bomb_pass', 'powerup_flame_pass', + 'powerup_diagonal', + 'gem_blue', + 'gem_green', + 'gem_red', + 'gem_yellow', + 'charge_nuke', + 'charge_shield', + 'charge_freeze', + 'charge_decoy', ] as const export type BoardSprite = (typeof BOARD)[number] @@ -109,7 +109,17 @@ const tiles = new Map<BoardSprite, TileInfo>() /** Only after `engineInit` has loaded the textures. */ export function sprite(name: BoardSprite): TileInfo { let t = tiles.get(name) - if (!t) tiles.set(name, (t = tile(0, vec2(128), BOARD.indexOf(name)))) + if (!t) { + // A name from saved or changed data that isn't loaded: say which, rather than LittleJS's + // assertion on texture -1 (in its development build) or a blank tile. + const index = BOARD.indexOf(name) + if (index < 0) throw new Error(`Sprite not loaded: ${String(name)}`) + try { + tiles.set(name, (t = tile(0, vec2(128), index))) + } catch (error) { + throw new Error(`Sprite ${name} (texture ${index}): ${String(error)}`, { cause: error }) + } + } return t } @@ -137,15 +147,19 @@ export function wallSprite(level: number, width: number, height: number, p: { x: return top || bottom ? 'terrain_stone_horizontal_middle' : 'terrain_stone_vertical_middle' } -/** The icon for each action charge, on the board (as a drop) and on its button. */ +/** + * The icon for each charge, on the board (as a drop), in the top bar while it lasts, and as a + * bomb's badge (user's choices, 2026-10-23): Pierce is the Wall Pass icon, Power the Fire+ icon; + * Nuke a bomb with a skull badge; Shield the + * figure in an empty ring; Freeze a locked padlock; Decoy three pawns (tools/compose-icons.sh). + */ export const CHARGE_ICONS: Record<ChargeId, BoardSprite> = { - shield: 'switch_blue', - freeze: 'chain', - diagonal: 'ramp', - pierce: 'spikes', - power: 'weight', - line: 'bridge_logs', - decoy: 'hud_player_helmet_green', + pierce: 'powerup_wall_pass', + power: 'powerup_fire_plus', + nuke: 'charge_nuke', + shield: 'charge_shield', + freeze: 'charge_freeze', + decoy: 'charge_decoy', } /** @@ -164,6 +178,14 @@ export function coinSprite(amount: number): BoardSprite { return amount >= 5 ? 'coin_gold' : amount >= 2 ? 'coin_silver' : 'coin_bronze' } +/** A level's gem, in its colour (user request, 2026-10-23). */ +export const GEM_SPRITES: Record<GemColor, BoardSprite> = { + blue: 'gem_blue', + green: 'gem_green', + red: 'gem_red', + yellow: 'gem_yellow', +} + /** * 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 @@ -186,4 +208,5 @@ export const POWER_UP_ICONS: Record<PowerUpKind, BoardSprite> = { kick: 'powerup_kick', throw: 'powerup_throw', teleport: 'powerup_teleport', + diagonal: 'powerup_diagonal', } diff --git a/src/sim/actions.ts b/src/sim/actions.ts index 8e67bd8..84d839b 100644 --- a/src/sim/actions.ts +++ b/src/sim/actions.ts @@ -11,8 +11,6 @@ export interface ActionDef { label: string /** False for actions like Wait that happen as soon as they are picked. */ needsTarget: boolean - /** The action charge it spends (PLAN.md §3.3); only offered while the player holds one. */ - charge?: ChargeId /** The power-up ability it needs (PLAN.md §3.2); only offered once the player has it. */ ability?: keyof Powers validTargets(s: GameState): Vec[] @@ -28,11 +26,16 @@ function canHoldBomb(s: GameState, p: Vec): boolean { return isFree(s, p) && !isBurning(s, p) && !enemyAt(s, p) } -function placeBomb(s: GameState, pos: Vec, events: GameEvent[], special?: Bomb['special']): void { +/** A bomb from the player, with every boost they have left (Pierce, Power, Nuke), each using one. */ +function placeBomb(s: GameState, pos: Vec, events: GameEvent[]): void { + const bomb: Bomb = { pos: { ...pos }, timer: s.stats.fuse, fire: s.stats.fire, placedTurn: s.turn } + for (const kind of BOMB_BOOSTS) { + if (s.boosts[kind] <= 0) continue + s.boosts[kind]-- + bomb[kind] = true + } // A Power bomb's flame reaches as far as the level is wide: in practice, to the walls. - const fire = special === 'power' ? Math.max(s.width, s.height) : s.stats.fire - const bomb: Bomb = { pos: { ...pos }, timer: s.stats.fuse, fire, placedTurn: s.turn } - if (special) bomb.special = special + if (bomb.power) bomb.fire = Math.max(s.width, s.height) s.bombs.push(bomb) events.push({ type: 'bombPlaced', pos: { ...pos }, timer: s.stats.fuse }) } @@ -55,12 +58,43 @@ function walkTo(s: GameState, target: Vec, events: GameEvent[]): void { /** 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). + * Freeze: the enemy moves skipped, the pickup turn's included (user request, 2026-10-23: "frozen + * for 3 turns"; it was this turn and the next 3 while Freeze was an action). */ -export const FREEZE_TURNS = 4 +export const FREEZE_TURNS = 3 /** How many enemy plannings a Decoy lasts. */ export const DECOY_TURNS = 3 +/** How many bombs one Pierce, Power or Nuke charge changes (user's choice, 2026-10-23). */ +export const BOMB_BOOST = 3 +/** How far a Nuke bomb's blast reaches, counted across plus down, through anything (user's choice). */ +export const NUKE_RANGE = 2 + +const BOMB_BOOSTS = ['pierce', 'power', 'nuke'] as const + +/** + * A charge picked up works at once (user request, 2026-10-23: they were actions before). + * Picking up more of a kind adds to it: BOMB_BOOST more bombs. + */ +export function applyCharge(s: GameState, kind: ChargeId, events: GameEvent[]): void { + switch (kind) { + case 'pierce': + case 'power': + case 'nuke': + s.boosts[kind] += BOMB_BOOST + break + case 'shield': + s.shield = true + break + case 'freeze': + s.frozen = Math.max(s.frozen, FREEZE_TURNS) + break + case 'decoy': + // On the pickup tile: the player steps off it, and chasing enemies go there instead. + s.decoy = { pos: { ...s.player }, turns: DECOY_TURNS } + events.push({ type: 'decoyPlaced', pos: s.decoy.pos }) + break + } +} /** Where a bomb kicked by the player slides to: on, away from them, until the next tile can't hold it. */ export function kickDestination(s: GameState, from: Vec): Vec { @@ -151,31 +185,11 @@ export const ACTIONS: Record<ActionId, ActionDef> = { : DIR_LIST.map((d) => add(s.player, { x: 2 * DIRS[d].x, y: 2 * DIRS[d].y })).filter((p) => canHoldBomb(s, p)), apply: (s, target, events) => placeBomb(s, target!, events), }, - shield: { - id: 'shield', - label: 'Shield', - needsTarget: false, - charge: 'shield', - validTargets: () => [], - apply(s) { - s.shield = true - }, - }, - freeze: { - id: 'freeze', - label: 'Freeze', - needsTarget: false, - charge: 'freeze', - validTargets: () => [], - apply(s) { - s.frozen = FREEZE_TURNS - }, - }, diagonal: { id: 'diagonal', label: 'Diagonal', needsTarget: true, - charge: 'diagonal', + ability: 'diagonal', // A squeeze: the two orthogonal tiles in between may both be blocked. validTargets: (s) => [ @@ -205,57 +219,9 @@ export const ACTIONS: Record<ActionId, ActionDef> = { }, apply: (s, target, events) => walkTo(s, target!, events), }, - pierce: { - id: 'pierce', - label: 'Pierce', - needsTarget: true, - charge: 'pierce', - validTargets: bombTargets, - apply: (s, target, events) => placeBomb(s, target!, events, 'pierce'), - }, - power: { - id: 'power', - label: 'Power', - needsTarget: true, - charge: 'power', - validTargets: bombTargets, - apply: (s, target, events) => placeBomb(s, target!, events, 'power'), - }, - line: { - id: 'line', - label: 'Line', - needsTarget: true, - charge: 'line', - // The tile picked sets the direction: bombs go there and on beyond it, one per free tile, - // until capacity runs out or the line is blocked. - validTargets: bombTargets, - apply(s, target, events) { - const d = { x: target!.x - s.player.x, y: target!.y - s.player.y } - for (let p = target!; hasCapacity(s) && canHoldBomb(s, p); p = add(p, d)) placeBomb(s, p, events) - }, - }, - decoy: { - id: 'decoy', - label: 'Decoy', - needsTarget: true, - charge: 'decoy', - validTargets: (s) => neighbours(s.player).filter((p) => canHoldBomb(s, p)), - apply(s, target, events) { - s.decoy = { pos: { ...target! }, turns: DECOY_TURNS } - events.push({ type: 'decoyPlaced', pos: s.decoy.pos }) - }, - }, } -export const ACTION_ORDER: readonly ActionId[] = [ - 'move', 'bomb', 'wait', 'run', 'detonate', 'kick', 'throw', 'shield', 'freeze', 'diagonal', 'teleport', - 'pierce', 'power', 'line', 'decoy', -] - -/** The action charge an action spends, if it is a charge action. */ -export function chargeOf(id: ActionId): ChargeId | undefined { - return ACTIONS[id].charge -} +export const ACTION_ORDER: readonly ActionId[] = ['move', 'bomb', 'wait', 'run', 'detonate', 'kick', 'throw', 'diagonal', 'teleport'] export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean { return ACTIONS[id].validTargets(s).some((p) => eq(p, target)) @@ -263,15 +229,9 @@ export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean /** Whether the action can be picked right now. */ export function isAvailable(s: GameState, id: ActionId): boolean { - if (s.status !== 'playing') return false + if (s.status !== 'playing' || s.offer) return false const ability = ACTIONS[id].ability if (ability && !s.powers[ability]) return false - const charge = chargeOf(id) - if (charge) { - if (s.charges[charge] <= 0) return false - if (id === 'shield' && s.shield) return false - if (id === 'freeze' && s.enemies.length === 0) return false - } const def = ACTIONS[id] return !def.needsTarget || def.validTargets(s).length > 0 } diff --git a/src/sim/demo.ts b/src/sim/demo.ts new file mode 100644 index 0000000..efdb080 --- /dev/null +++ b/src/sim/demo.ts @@ -0,0 +1,162 @@ +import { ACTIONS, isAvailable } from './actions' +import { planIntents } from './enemies' +import { Rng } from './rng' +import { noBoosts, noPowers, START_LIVES, START_STATS } from './state' +import { step } from './turn' +import { DEFAULT_OPTIONS, type Action, type ActionId, type Cell, type EnemyKind, type GameEvent, type GameState, type Vec } from './types' + +/** + * The demo played before the game starts, for players who never played the original (user + * request, 2026-10-23). Each scene is made up on the spot: a small random room, then random play + * on it, by the real rules, until a game shows what the scene is about. The scene is that room + * and those actions; the UI replays them, view-only. Pure and seeded like the rest of the sim. + */ +export type SceneKind = 'kill' | 'escape' | 'chain' | 'freeze' | 'detonate' + +export const SCENE_KINDS: readonly SceneKind[] = ['kill', 'escape', 'chain', 'freeze', 'detonate'] + +/** What each scene shows, in one line under it. */ +export const SCENE_CAPTIONS: Record<SceneKind, string> = { + kill: 'Bombs count down with your moves. Their flame kills enemies.', + escape: 'Step out of the blast just in time.', + chain: 'A blast sets off every bomb it reaches.', + freeze: 'Freeze stops every enemy in its tracks. Then bomb them.', + detonate: 'With the Detonator, set a bomb off right when an enemy walks in.', +} + +export interface Scene { + kind: SceneKind + start: GameState + actions: Action[] +} + +const WIDTH = 9 +const HEIGHT = 7 +/** How many turns a scene may take, how many random games are tried per room, and how many rooms. */ +const MAX_TURNS = 24 +const TRIES = 150 +const ROOMS = 6 + +/** What each scene starts with: enemies, and what the player has. */ +const SETUP: Record<SceneKind, { enemies: EnemyKind[]; prepare?: (s: GameState, rng: Rng) => void }> = { + kill: { enemies: ['blob', 'seeker'] }, + escape: { enemies: ['seeker'] }, + chain: { enemies: ['blob', 'blob'], prepare: (s) => (s.stats.capacity = 2) }, + // A Freeze charge two steps from the player, to walk over. + freeze: { enemies: ['seeker', 'blob', 'blob'], prepare: (s, rng) => dropNear(s, rng, 'freeze') }, + // A long fuse, so the Detonator is what sets the bomb off. + detonate: { enemies: ['blob', 'seeker'], prepare: (s) => ((s.powers.detonate = true), (s.stats.fuse = 6)) }, +} + +/** A scene of `kind`, from `seed`; null if no room gave one (rare). */ +export function generateScene(kind: SceneKind, seed: number): Scene | null { + const rng = new Rng(seed) + for (let room = 0; room < ROOMS; room++) { + const start = makeRoom(kind, rng) + let best: Action[] | null = null + for (let t = 0; t < TRIES; t++) { + const actions = rollout(kind, start, rng) + if (actions && (!best || actions.length < best.length)) best = actions + // A short one is good enough; a few more tries look for a shorter one. + if (best && best.length <= 10) break + } + if (best) return { kind, start, actions: best } + } + return null +} + +/** A small room: walls round it, pillars on every other tile, a few crates; the player and enemies apart. */ +function makeRoom(kind: SceneKind, rng: Rng): GameState { + const cells: Cell[] = [] + for (let y = 0; y < HEIGHT; y++) { + for (let x = 0; x < WIDTH; x++) { + const edge = x === 0 || y === 0 || x === WIDTH - 1 || y === HEIGHT - 1 + cells.push(edge || (x % 2 === 0 && y % 2 === 0) ? 'wall' : 'floor') + } + } + const floor = () => cells.flatMap((c, i) => (c === 'floor' ? [{ x: i % WIDTH, y: Math.floor(i / WIDTH) }] : [])) + const tiles = floor() + const player = tiles[rng.int(tiles.length)]! + const far = (p: Vec) => Math.abs(p.x - player.x) + Math.abs(p.y - player.y) + // Crates on about one free tile in six, never next to the player (room to place a bomb and go). + for (const p of tiles) if (far(p) > 2 && rng.next() < 0.16) cells[p.y * WIDTH + p.x] = 'block' + const s: GameState = { + seed: rng.int(2 ** 31), options: { ...DEFAULT_OPTIONS, size: 'small' }, level: 1, turn: 0, width: WIDTH, height: HEIGHT, + cells, player: { ...player }, lives: START_LIVES, stats: { ...START_STATS }, bombs: [], status: 'playing', + enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 99, coins: 0, + boosts: noBoosts(), shield: false, frozen: 0, decoy: null, burning: [], + powers: noPowers(), star: 0, powerUp: null, offer: null, runTurns: 0, deaths: 0, crumbling: [], + aiRng: rng.int(2 ** 31), dropRng: rng.int(2 ** 31), + } + const open = floor().filter((p) => far(p) >= 3) + for (const enemy of SETUP[kind].enemies) { + if (!open.length) break + const pos = open.splice(rng.int(open.length), 1)[0]! + s.enemies.push({ id: s.nextEnemyId++, kind: enemy, pos, dir: 'left', plan: [], spawned: false, sheltered: false }) + } + SETUP[kind].prepare?.(s, rng) + planIntents(s) + return s +} + +/** Put a charge on a free floor tile two steps from the player. */ +function dropNear(s: GameState, rng: Rng, kind: 'freeze'): void { + const near = s.cells.flatMap((c, i) => { + const p = { x: i % s.width, y: Math.floor(i / s.width) } + return c === 'floor' && Math.abs(p.x - s.player.x) + Math.abs(p.y - s.player.y) === 2 && !s.enemies.some((e) => e.pos.x === p.x && e.pos.y === p.y) ? [p] : [] + }) + if (near.length) s.drops.push({ pos: near[rng.int(near.length)]!, kind }) +} + +/** One random game from `start`; its actions if it shows the scene, otherwise null. */ +function rollout(kind: SceneKind, start: GameState, rng: Rng): Action[] | null { + let s = start + const actions: Action[] = [] + const seen = { froze: -1 } + for (let turn = 0; turn < MAX_TURNS && s.status === 'playing'; turn++) { + // A random action that doesn't get the player killed this turn (a few goes at it). + let next: { action: Action; state: GameState; events: GameEvent[] } | null = null + for (let go = 0; go < 4 && !next; go++) { + const action = randomAction(s, rng) + const r = step(s, action) + if (r.state.status === 'playing' || r.state.status === 'cleared') next = { action, state: r.state, events: r.events } + } + if (!next) return null + actions.push(next.action) + if (shows(kind, s, next.state, next.events, seen, turn)) return actions + s = next.state + } + return null +} + +/** Whether this turn (from `prev` to `next`, with its events) is the moment the scene is about. */ +function shows(kind: SceneKind, prev: GameState, next: GameState, events: GameEvent[], seen: { froze: number }, turn: number): boolean { + const killed = events.some((e) => e.type === 'enemyDied') + const exploded = events.filter((e) => e.type === 'exploded') + switch (kind) { + case 'kill': + return next.enemies.length === 0 + case 'escape': + // Stood in a blast's reach, stepped out of it on the very turn it went off. + return exploded.some((e) => e.type === 'exploded' && e.tiles.some((t) => t.x === prev.player.x && t.y === prev.player.y)) + case 'chain': + return exploded.length >= 2 && killed + case 'freeze': + if (events.some((e) => e.type === 'pickedUp' && e.drop.kind === 'freeze')) seen.froze = turn + return seen.froze >= 0 && killed && prev.frozen > 0 + case 'detonate': + return events.some((e) => e.type === 'detonated') && killed + } +} + +/** Weights for a random action: mostly moving, bombing now and then, sometimes waiting. */ +const WEIGHTS: Partial<Record<ActionId, number>> = { move: 5, bomb: 2, wait: 1, detonate: 3 } + +function randomAction(s: GameState, rng: Rng): Action { + const choices = (Object.keys(WEIGHTS) as ActionId[]).filter((id) => isAvailable(s, id)) + let r = rng.next() * choices.reduce((sum, id) => sum + WEIGHTS[id]!, 0) + const id = choices.find((c) => (r -= WEIGHTS[c]!) < 0) ?? 'wait' + if (!ACTIONS[id].needsTarget) return { id } + const targets = ACTIONS[id].validTargets(s) + return { id, target: targets[rng.int(targets.length)]! } +} diff --git a/src/sim/enemies.ts b/src/sim/enemies.ts index 7cbd149..f152232 100644 --- a/src/sim/enemies.ts +++ b/src/sim/enemies.ts @@ -20,6 +20,8 @@ interface Step { interface Walker { pos: Vec dir: Dir + /** Whether another enemy, planned before this one, will be on `p` at the move being planned. */ + crowded(p: Vec): boolean } /** The direction of a one-tile step. */ @@ -30,21 +32,31 @@ export function dirBetween(from: Vec, to: Vec): Dir { /** How many moves ahead every enemy plans, and shows (PLAN.md §4). */ export const PLAN_AHEAD = 3 -function openSteps(s: GameState, w: Walker): Step[] { +function openSteps(s: GameState, w: Pick<Walker, 'pos'>): Step[] { return DIR_LIST.map((dir) => ({ dir, to: add(w.pos, DIRS[dir]) })).filter((st) => enemyCanEnter(s, st.to)) } +/** + * The open steps that keep clear of the other enemies (user request, 2026-10-23: enemies on one + * tile went everywhere together). Only when every open step is crowded is a crowded one used. + */ +function spreadSteps(s: GameState, w: Walker): Step[] { + const steps = openSteps(s, w) + const clear = steps.filter((st) => !w.crowded(st.to)) + return clear.length ? clear : steps +} + /** Blob: carries on the way it was going three times in four, otherwise any open way. */ function wander(s: GameState, w: Walker, rng: Rng): Step | null { - const steps = openSteps(s, w) + const steps = spreadSteps(s, w) const ahead = steps.find((st) => st.dir === w.dir) if (ahead && rng.next() < 0.75) return ahead return steps.length ? steps[rng.int(steps.length)]! : null } -/** Drifter: straight on until blocked, then any open way. */ +/** Drifter: straight on until blocked (or the way ahead is another enemy's), then any open way. */ function drift(s: GameState, w: Walker, rng: Rng): Step | null { - const steps = openSteps(s, w) + const steps = spreadSteps(s, w) return steps.find((st) => st.dir === w.dir) ?? (steps.length ? steps[rng.int(steps.length)]! : null) } @@ -52,7 +64,7 @@ function drift(s: GameState, w: Walker, rng: Rng): Step | null { * The first step of a shortest path over tiles enemies can enter toward what they chase: the * decoy while there is one, otherwise the player. Null if there is no way. */ -export function stepToward(s: GameState, w: Walker): Step | null { +export function stepToward(s: GameState, w: Pick<Walker, 'pos'>): Step | null { const goal = s.decoy?.pos ?? s.player const key = (p: Vec) => p.y * s.width + p.x const first = new Map<number, Step>() @@ -77,8 +89,12 @@ export function stepToward(s: GameState, w: Walker): Step | null { const PLANNERS: Record<EnemyKind, (s: GameState, w: Walker, rng: Rng) => Step | null> = { blob: wander, - // Seeker: half the time a step along the shortest path to the player, otherwise a Blob's wander. - seeker: (s, w, rng) => (rng.next() < 0.5 ? stepToward(s, w) : null) ?? wander(s, w, rng), + // Seeker: half the time a step along the shortest path to the player (unless another enemy + // takes that tile then), otherwise a Blob's wander. + seeker: (s, w, rng) => { + const toward = rng.next() < 0.5 ? stepToward(s, w) : null + return toward && !w.crowded(toward.to) ? toward : wander(s, w, rng) + }, drifter: drift, } @@ -87,9 +103,14 @@ const PLANNERS: Record<EnemyKind, (s: GameState, w: Walker, rng: Rng) => Step | * make. Planned steps the board no longer allows (a bomb now in the way) are dropped first, * along with everything after them, and planned again from there. The steps are worked out on * the board as it is now, so a later bomb can still block one. + * Enemies are planned in turn, each keeping clear of where the ones before it will be at each + * of the coming moves (`spreadSteps`), so they spread out instead of moving as one. */ export function planIntents(s: GameState): void { const rng = new Rng(s.aiRng) + // "t:x,y": an enemy planned so far will be at x,y after t moves (it stays where its plan ends). + const taken = new Set<string>() + const at = (t: number, p: Vec) => `${t}:${p.x},${p.y}` for (const e of s.enemies) { let from = e.pos const kept: Vec[] = [] @@ -100,7 +121,11 @@ export function planIntents(s: GameState): void { } // Carry on from the last kept step, heading the way that step went. const last = kept.at(-1) - const w: Walker = { pos: from, dir: last ? dirBetween(kept.at(-2) ?? e.pos, last) : e.dir } + const w: Walker = { + pos: from, + dir: last ? dirBetween(kept.at(-2) ?? e.pos, last) : e.dir, + crowded: (p) => taken.has(at(e.plan.length + 1, p)), + } e.plan = kept while (e.plan.length < PLAN_AHEAD) { const st = PLANNERS[e.kind](s, w, rng) @@ -110,6 +135,7 @@ export function planIntents(s: GameState): void { w.dir = st.dir } e.dir = w.dir + for (let t = 1; t <= PLAN_AHEAD; t++) taken.add(at(t, e.plan[t - 1] ?? e.plan.at(-1) ?? e.pos)) } s.aiRng = rng.state } @@ -144,13 +170,24 @@ const DROP_CHANCE: Record<EnemyKind, { coins: number; charge: number }> = { drifter: { coins: 0.2, charge: 0.5 }, } -/** Every charge is equally likely. */ -function pickCharge(rng: Rng): ChargeId { - return CHARGE_IDS[Math.floor(rng.next() * CHARGE_IDS.length)]! +/** + * The charges that still help once every enemy is gone: they go on to the next level. Freeze + * and Decoy only work on enemies, so dropped by the last one they were wasted (user report, + * 2026-10-23; the user's choice of fix). + */ +export const LASTING_CHARGES: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'shield'] + +/** Every charge is equally likely; with `last`, every lasting one. */ +function pickCharge(rng: Rng, last: boolean): ChargeId { + const ids = last ? LASTING_CHARGES : CHARGE_IDS + return ids[Math.floor(rng.next() * ids.length)]! } -/** What a slain enemy leaves on its tile. Coins are worth 1 (60%), 2 (30%) or 5 (10%). */ -export function rollDrops(s: GameState, e: Enemy): Drop[] { +/** + * What a slain enemy leaves on its tile. Coins are worth 1 (60%), 2 (30%) or 5 (10%). `last`: + * no enemies are left, so a charge is a lasting one. + */ +export function rollDrops(s: GameState, e: Enemy, last = false): Drop[] { if (e.spawned) return [] const rng = new Rng(s.dropRng) const drops: Drop[] = [] @@ -160,7 +197,7 @@ export function rollDrops(s: GameState, e: Enemy): Drop[] { drops.push({ pos: e.pos, kind: 'coins', amount: r < 0.6 ? 1 : r < 0.9 ? 2 : 5 }) } if (rng.next() < chance.charge) { - drops.push({ pos: e.pos, kind: pickCharge(rng) }) + drops.push({ pos: e.pos, kind: pickCharge(rng, last) }) } s.dropRng = rng.state return drops diff --git a/src/sim/explosions.ts b/src/sim/explosions.ts index 0f44f06..1f60b85 100644 --- a/src/sim/explosions.ts +++ b/src/sim/explosions.ts @@ -1,12 +1,14 @@ +import { NUKE_RANGE } from './actions' import { add, bombAt, cellAt, idx } from './state' import { DIRS, DIR_LIST, type Bomb, type GameEvent, type GameState, type Vec } from './types' /** * The tiles a bomb's flame reaches: its own tile, then up to `fire` tiles in each direction. * Flame stops before a hard wall, and stops *on* a soft block or another bomb (burning it). - * A Pierce bomb's flame carries on through soft blocks. + * A Pierce bomb's flame carries on through soft blocks. A Nuke bomb also burns every tile within + * NUKE_RANGE steps (across plus down), through anything, except walls, which still stand. */ -export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'special'>): Vec[] { +export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'pierce' | 'nuke'>): Vec[] { const tiles = [bomb.pos] for (const d of DIR_LIST) { let p = bomb.pos @@ -15,7 +17,16 @@ export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'spec const cell = cellAt(s, p) if (cell === 'wall') break tiles.push(p) - if ((cell === 'block' && bomb.special !== 'pierce') || bombAt(s, p)) break + if ((cell === 'block' && !bomb.pierce) || bombAt(s, p)) break + } + } + if (bomb.nuke) { + for (let dy = -NUKE_RANGE; dy <= NUKE_RANGE; dy++) { + for (let dx = -NUKE_RANGE; dx <= NUKE_RANGE; dx++) { + const p = add(bomb.pos, { x: dx, y: dy }) + if (Math.abs(dx) + Math.abs(dy) > NUKE_RANGE || cellAt(s, p) === 'wall') continue + if (!tiles.some((t) => t.x === p.x && t.y === p.y)) tiles.push(p) + } } } return tiles diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts index dbb6bed..c9371e0 100644 --- a/src/sim/levelgen.ts +++ b/src/sim/levelgen.ts @@ -1,5 +1,5 @@ -import { hashSeed, stream, type Rng } from './rng' -import { DIR_LIST, type BoardSize, type Cell, type Dir, type EnemyKind, type PowerUp, type PowerUpKind, type Vec } from './types' +import { stream } from './rng' +import { DIR_LIST, type BoardSize, type Cell, type Dir, type EnemyKind, type GemColor, type PowerUp, type Vec } from './types' /** The NES layout: 31 × 13 with a wall all round and a pillar at every even (x, y). */ export const LEVEL_WIDTH = 31 @@ -62,28 +62,8 @@ export function lastLevel(size: BoardSize): number { return SIZES[size].lastLevel } -/** - * 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], -] - -/** 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 === 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] -} +/** A gem's colour is only how it looks. */ +const GEM_COLORS: readonly GemColor[] = ['blue', 'green', 'red', 'yellow'] export interface EnemyStart { kind: EnemyKind @@ -143,9 +123,9 @@ export function generateLevel(seed: number, level: number, size: BoardSize = 'no const i = open.splice(rng.int(open.length), 1)[0]! enemies.push({ kind: kinds[rng.int(kinds.length)]!, pos: at(i), dir: DIR_LIST[rng.int(4)]! }) } - // Any other block, near the start or not, so an early power-up is possible. + // Any other block, near the start or not, so an early gem is possible. const others = cells.flatMap((c, i) => (c === 'block' && !(i % width === exit.x && Math.floor(i / width) === exit.y) ? [i] : [])) - const powerUp = { pos: at(others[rng.int(others.length)]!), kind: pickPowerUp(seed, level, rng) } + const powerUp = { pos: at(others[rng.int(others.length)]!), color: GEM_COLORS[rng.int(GEM_COLORS.length)]! } return { width, height, cells, start: { ...START }, exit, powerUp, enemies, par: parFor(enemies.length, size) } } diff --git a/src/sim/path.ts b/src/sim/path.ts index fbc1854..a914f54 100644 --- a/src/sim/path.ts +++ b/src/sim/path.ts @@ -1,4 +1,4 @@ -import { add, eq, playerCanPass } from './state' +import { add, eq, exitOpen, playerCanPass } from './state' import { DIRS, DIR_LIST, type GameState, type Vec } from './types' /** @@ -7,17 +7,20 @@ import { DIRS, DIR_LIST, type GameState, type Vec } from './types' * tile and ends at `to`; null if `to` can't be reached. * Enemies don't block the path, because they will have moved by the time the player gets there; * the UI refuses a step onto an enemy instead. + * An open exit is only walked onto as the destination: passing over it would end the level on + * the way somewhere else (user report, 2026-10-23). */ export function playerPath(s: GameState, to: Vec): Vec[] | null { if (eq(s.player, to) || !playerCanPass(s, to)) return null const key = (p: Vec) => p.y * s.width + p.x const prev = new Map<number, Vec>([[key(s.player), s.player]]) + const avoidExit = exitOpen(s) && !eq(s.exit, to) const queue = [s.player] for (let i = 0; i < queue.length; i++) { const p = queue[i]! for (const d of DIR_LIST) { const q = add(p, DIRS[d]) - if (prev.has(key(q)) || !playerCanPass(s, q)) continue + if (prev.has(key(q)) || !playerCanPass(s, q) || (avoidExit && eq(q, s.exit))) continue prev.set(key(q), p) if (eq(q, to)) { const path = [q] diff --git a/src/sim/record.ts b/src/sim/record.ts index 4a8ae4b..805408d 100644 --- a/src/sim/record.ts +++ b/src/sim/record.ts @@ -6,7 +6,7 @@ import { DEFAULT_OPTIONS, type Action, type GameEvent, type GameState, type RunO * 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 = 7 +export const SIM_VERSION = 15 /** 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 fd86ed4..8337bab 100644 --- a/src/sim/state.ts +++ b/src/sim/state.ts @@ -1,7 +1,8 @@ import { planIntents } from './enemies' import { generateLevel } from './levelgen' import { hashSeed } from './rng' -import { CHARGE_IDS, DEFAULT_OPTIONS, type Bomb, type Cell, type ChargeId, type GameState, type Powers, type RunOptions, type Stats, type Vec } from './types' +import { BOMB_BOOST } from './actions' +import { DEFAULT_OPTIONS, type Bomb, type Boosts, type Cell, type GameState, type Powers, type RunOptions, type Stats, type Vec } from './types' export const START_LIVES = 3 export const START_STATS: Stats = { capacity: 1, fire: 1, fuse: 3 } @@ -14,7 +15,9 @@ export interface Carry { lives: number stats: Stats coins: number - charges: Record<ChargeId, number> + boosts: Boosts + /** A shield picked up and not yet broken goes on to the next level. */ + shield: boolean powers: Powers star: number runTurns: number @@ -22,26 +25,22 @@ export interface Carry { } export function noPowers(): Powers { - return { run: false, kick: false, throw: false, teleport: false, detonate: false, wallPass: false, bombPass: false, flamePass: false } + return { run: false, kick: false, throw: false, teleport: false, diagonal: false, detonate: false, wallPass: false, bombPass: false, flamePass: false } } -/** `n` of every action charge. */ -export function charges(n: number): Record<ChargeId, number> { - return Object.fromEntries(CHARGE_IDS.map((id) => [id, n])) as Record<ChargeId, number> +export function noBoosts(): Boosts { + return { pierce: 0, power: 0, nuke: 0 } } -export function noCharges(): Record<ChargeId, number> { - return charges(0) -} - -/** Developer mode's stats, and its charges (shown as unlimited; never spent). */ -export const DEV_STATS: Stats = { capacity: 5, fire: 5, fuse: 3 } -export const DEV_CHARGES = 99 - -/** `n`: start with this many of every action charge (a testing aid, `?charges=` in the URL). */ +/** + * `n`: start as if `n` of each lasting charge had been picked up — Pierce, Power, Nuke (n × + * BOMB_BOOST bombs each) and a Shield; not Freeze or Decoy, which act on the + * spot (a testing aid, `?charges=` in the URL). + */ export function newRun(seed: number, level = 1, n = 0, options: RunOptions = DEFAULT_OPTIONS): GameState { + const boosts = n > 0 ? { pierce: n * BOMB_BOOST, power: n * BOMB_BOOST, nuke: n * BOMB_BOOST } : noBoosts() return createLevel(seed, level, options, { - lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charges: charges(n), powers: noPowers(), star: 0, + lives: START_LIVES, stats: { ...START_STATS }, coins: 0, boosts, shield: n > 0, powers: noPowers(), star: 0, runTurns: 0, deaths: 0, }) } @@ -54,34 +53,31 @@ export function createLevel(seed: number, level: number, options: RunOptions, ca const { width, height, cells, start, exit, powerUp, enemies, par } = generateLevel(seed, level, options.size) const s: GameState = { seed, options: { ...options }, level, turn: 0, width, height, cells, player: start, - lives: carry.lives, stats: { ...carry.stats }, coins: carry.coins, charges: { ...carry.charges }, + lives: carry.lives, stats: { ...carry.stats }, coins: carry.coins, boosts: { ...carry.boosts }, bombs: [], status: 'playing', - enemies: enemies.map((e, id) => ({ id, kind: e.kind, pos: e.pos, dir: e.dir, plan: [], spawned: false })), + enemies: enemies.map((e, id) => ({ id, kind: e.kind, pos: e.pos, dir: e.dir, plan: [], spawned: false, sheltered: false })), nextEnemyId: enemies.length, - drops: [], exit, par, shield: false, frozen: 0, decoy: null, burning: [], - powers: { ...carry.powers }, star: carry.star, powerUp, runTurns: carry.runTurns, deaths: carry.deaths, crumbling: [], + drops: [], exit, par, shield: carry.shield, frozen: 0, decoy: null, burning: [], + powers: { ...carry.powers }, star: carry.star, powerUp, offer: null, 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, 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) - } + // Developer mode: only this (user request, 2026-10-23; it used to give every ability, stats and + // unlimited charges). The level's gem lies uncovered right of the start (always open floor), + // every level and after every death, to try the choice at once. + if (options.dev) s.powerUp = { pos: { x: start.x + 1, y: start.y }, color: powerUp.color } planIntents(s) return s } /** - * What the player takes on. After a death, the NES rule: Wall Pass, Bomb Pass, Flame Pass, - * 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.) + * What the player takes on, to the next level or the retry after a death: everything. Since + * 2026-10-23 (user request) a death costs no power-ups; the NES rule lost the passes, Detonator + * and Star. (A Star can't be running at a death: nothing hurts the player while it lasts.) + * (The Keep / Fresh choice of PLAN.md §9 will decide about the stats and Run.) */ -export function carryOf(s: GameState, died = false): Carry { - const { run, kick, throw: canThrow, teleport } = s.powers - const powers = died ? { ...noPowers(), run, kick, throw: canThrow, teleport } : s.powers +export function carryOf(s: GameState): Carry { return { - lives: s.lives, stats: s.stats, coins: s.coins, charges: s.charges, powers, star: died ? 0 : s.star, + lives: s.lives, stats: s.stats, coins: s.coins, boosts: s.boosts, shield: s.shield, powers: s.powers, star: s.star, runTurns: s.runTurns, deaths: s.deaths, } } diff --git a/src/sim/turn.ts b/src/sim/turn.ts index ec8a83e..f679458 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -1,10 +1,10 @@ -import { ACTIONS, chargeOf, isAvailable, isValidTarget } from './actions' +import { ACTIONS, applyCharge, isAvailable, isValidTarget } from './actions' import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies' import { enemyKinds, lastLevel } from './levelgen' import { detonate, tickBombs } from './explosions' import { add, bombAt, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, PAR_BONUS } from './state' import { stream } from './rng' -import { DIRS, DIR_LIST, type Action, type GameEvent, type GameState, type PowerUpKind, type Vec } from './types' +import { DIRS, DIR_LIST, type Action, type GameEvent, type GameState, type Powers, type PowerUpKind, type Vec } from './types' export interface StepResult { state: GameState @@ -26,10 +26,21 @@ export function step(prev: GameState, action: Action): StepResult { const s = cloneState(prev) const events: GameEvent[] = [] + // The gem's choice: no turn passes, nothing else moves. + if (action.id === 'choose') { + if (!s.offer?.includes(action.kind)) throw new Error(`${action.kind} is not on offer`) + applyPowerUp(s, action.kind) + s.offer = null + events.push({ type: 'powerUpChosen', kind: action.kind }) + return { state: s, events } + } + // An offer must be answered first, even with the level cleared; a death drops it. + if (s.offer && s.status !== 'dead') throw new Error(`${action.id} while a power-up is on offer`) + if (action.id === 'continue') { if (s.status === 'dead') { events.push({ type: 'levelRestarted' }) - return { state: createLevel(s.seed, s.level, s.options, carryOf(s, true)), events } + return { state: createLevel(s.seed, s.level, s.options, carryOf(s)), events } } if (s.status === 'cleared') { events.push({ type: 'levelStarted', level: s.level + 1 }) @@ -44,11 +55,6 @@ export function step(prev: GameState, action: Action): StepResult { if (def.needsTarget && (!action.target || !isValidTarget(s, action.id, action.target))) { throw new Error(`invalid target for ${action.id}: ${JSON.stringify(action.target)}`) } - const charge = chargeOf(action.id) - if (charge) { - if (!s.options.dev) s.charges[charge]-- - events.push({ type: 'chargeUsed', id: charge }) - } const from = s.player def.apply(s, action.target, events) // A run passes through the tile in between: anything there is picked up, or run into. @@ -112,15 +118,19 @@ export function step(prev: GameState, action: Action): StepResult { s.drops.splice(s.drops.indexOf(d), 1) events.push({ type: 'dropBurned', drop: d }) } - for (const e of s.enemies.filter((e) => fire.has(idx(s, e.pos)))) { - s.enemies.splice(s.enemies.indexOf(e), 1) + // Enemies let out by last turn's flame are only hurt by new explosions, not by that flame. + const slain = s.enemies.filter((e) => (e.sheltered ? burned : fire).has(idx(s, e.pos))) + s.enemies = s.enemies.filter((e) => !slain.includes(e)) + for (const e of slain) { events.push({ type: 'enemyDied', id: e.id, pos: e.pos }) - for (const drop of rollDrops(s, e)) { + // When these were the last, their charges are ones that last past the enemies (user's choice). + for (const drop of rollDrops(s, e, s.enemies.length === 0)) { drop.pos = dropSpot(s, e.pos) s.drops.push(drop) events.push({ type: 'dropSpawned', drop }) } } + for (const e of s.enemies) e.sheltered = false if (s.decoy && fire.has(idx(s, s.decoy.pos))) { events.push({ type: 'decoyGone', pos: s.decoy.pos, burned: true }) s.decoy = null @@ -155,6 +165,9 @@ export function step(prev: GameState, action: Action): StepResult { s.decoy = null } + // Died the turn a gem was taken: no choice. The retry has the gem back. + if (s.status === 'dead' || s.status === 'gameOver') s.offer = null + s.turn++ s.runTurns++ return { state: s, events } @@ -218,26 +231,65 @@ function crumble(s: GameState, events: GameEvent[]): void { function spawnPunishment(s: GameState, pos: Vec, events: GameEvent[]): void { const kinds = enemyKinds(s.level) for (let n = 0; n < EXIT_SPAWN; n++) { - s.enemies.push({ id: s.nextEnemyId++, kind: kinds[kinds.length - 1]!, pos: { ...pos }, dir: 'up', plan: [], spawned: true }) + s.enemies.push({ id: s.nextEnemyId++, kind: kinds[kinds.length - 1]!, pos: { ...pos }, dir: 'up', plan: [], spawned: true, sheltered: true }) } events.push({ type: 'enemiesSpawned', pos: { ...pos }, count: EXIT_SPAWN }) } -/** Collect every drop at `p`, and the level's power-up if it lies there uncovered. */ +/** Collect every drop at `p`, and the level's gem if it lies there uncovered (which sets up its offer). */ function pickUp(s: GameState, p: Vec, events: GameEvent[]): void { for (const d of s.drops.filter((d) => eq(d.pos, p))) { s.drops.splice(s.drops.indexOf(d), 1) if (d.kind === 'coins') s.coins += d.amount - else s.charges[d.kind]++ + else applyCharge(s, d.kind, events) events.push({ type: 'pickedUp', drop: d }) } if (s.powerUp && eq(s.powerUp.pos, p) && cellAt(s, p) === 'floor') { - applyPowerUp(s, s.powerUp.kind) - events.push({ type: 'powerUpTaken', powerUp: s.powerUp }) + s.offer = drawOffer(s) + events.push({ type: 'powerUpTaken', powerUp: s.powerUp, offer: s.offer }) s.powerUp = null } } +/** How many power-ups a gem offers (user request, 2026-10-23). */ +export const OFFER_SIZE = 5 + +/** + * How likely each power-up is to be offered, in the order an offer lists them (stats, + * abilities, passes, the rest). Run is in the draw like the rest; it was guaranteed on level 1 + * or 2 before gems (the user chose no guarantee). To tune by playtesting. + */ +export const POWER_UP_WEIGHTS: [PowerUpKind, number][] = [ + ['bombPlus', 20], ['firePlus', 20], ['fusePlus', 6], ['run', 10], ['kick', 8], ['throw', 8], ['teleport', 3], ['diagonal', 8], + ['detonator', 9], ['wallPass', 8], ['bombPass', 8], ['flamePass', 5], ['star', 8], ['heart', 4], ['coinBag', 12], +] + +/** The power-ups that are an ability or pass: offering one already held would change nothing. */ +const HELD_AS: Partial<Record<PowerUpKind, keyof Powers>> = { + run: 'run', kick: 'kick', throw: 'throw', teleport: 'teleport', diagonal: 'diagonal', detonator: 'detonate', + wallPass: 'wallPass', bombPass: 'bombPass', flamePass: 'flamePass', +} + +/** + * A gem's offer: OFFER_SIZE different power-ups (fewer if fewer would do anything), drawn by + * POWER_UP_WEIGHTS and listed in its order. From the seed and level, so a retry after a death + * offers the same. + */ +export function drawOffer(s: GameState): PowerUpKind[] { + const rng = stream(s.seed, 'offer', s.level) + const pool = POWER_UP_WEIGHTS.filter(([kind]) => { + const held = HELD_AS[kind] + return !held || !s.powers[held] + }) + const picked = new Set<PowerUpKind>() + while (picked.size < OFFER_SIZE && pool.length) { + let r = rng.next() * pool.reduce((sum, [, w]) => sum + w, 0) + const i = Math.max(0, pool.findIndex(([, w]) => (r -= w) < 0)) + picked.add(pool.splice(i, 1)[0]![0]) + } + return POWER_UP_WEIGHTS.map(([kind]) => kind).filter((kind) => picked.has(kind)) +} + function applyPowerUp(s: GameState, kind: PowerUpKind): void { switch (kind) { case 'bombPlus': s.stats.capacity++; break @@ -254,5 +306,6 @@ function applyPowerUp(s: GameState, kind: PowerUpKind): void { case 'kick': s.powers.kick = true; break case 'throw': s.powers.throw = true; break case 'teleport': s.powers.teleport = true; break + case 'diagonal': s.powers.diagonal = true; break } } diff --git a/src/sim/types.ts b/src/sim/types.ts index b93d3d8..963ba88 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -31,8 +31,12 @@ export interface Bomb { fire: number /** The turn it was placed on. A bomb is not ticked on the turn it is placed. */ placedTurn: number - /** Pierce: its flame goes through soft blocks, burning them all. Power: fire to the walls. */ - special?: 'pierce' | 'power' + /** Pierce: its flame goes through soft blocks, burning them all. Set from the player's boosts when placed. */ + pierce?: boolean + /** Power: its flame reaches to the walls. */ + power?: boolean + /** Nuke: it also burns every tile within NUKE_RANGE steps, through anything. */ + nuke?: boolean } export interface Stats { @@ -57,7 +61,7 @@ export type BoardSize = 'normal' | 'small' export interface RunOptions { /** normal: 31 × 13, 50 levels. small: 9 × 11, 20 levels, fewer enemies and crates. */ size: BoardSize - /** Developer mode: every ability and pass, unlimited charges, 5 bombs, blast 5 — for trying things. Still killable. */ + /** Developer mode: every level's gem lies uncovered right of the start, to try the choice at once. Nothing else. */ dev: boolean /** God mode: nothing can kill the player. */ god: boolean @@ -67,36 +71,45 @@ export interface RunOptions { export const DEFAULT_OPTIONS: RunOptions = { size: 'normal', dev: false, god: false, crumble: false } -/** The power-ups hidden one per level under a soft block (PLAN.md §5, after the NES eight). */ +/** The power-ups (PLAN.md §5, after the NES eight). Taking a level's gem offers a choice of them. */ export type PowerUpKind = | 'bombPlus' | 'firePlus' | 'fusePlus' | 'run' | 'wallPass' | 'bombPass' | 'flamePass' | 'detonator' - | 'star' | 'coinBag' | 'heart' | 'kick' | 'throw' | 'teleport' + | 'star' | 'coinBag' | 'heart' | 'kick' | 'throw' | 'teleport' | 'diagonal' +/** A gem's colour, from the seed. Only how it looks: every gem offers the same kind of choice. */ +export type GemColor = 'blue' | 'green' | 'red' | 'yellow' + +/** + * The level's gem, hidden one per level under a soft block. Taking it offers OFFER_SIZE + * power-ups to choose one from (user request, 2026-10-23: it used to be one fixed power-up). + */ export interface PowerUp { pos: Vec - kind: PowerUpKind + color: GemColor } /** - * Abilities from power-ups. Run, Detonate, Kick, Throw and Teleport are actions; the passes - * change where the player can go. + * Abilities from power-ups. Run, Detonate, Kick, Throw, Teleport and Diagonal are actions; the + * passes change where the player can go. All are kept through a death (since 2026-10-23). */ export interface Powers { - /** The Run power-up (called Skates until 2026-10-22): 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. */ run: boolean - /** Kick: walk into a bomb and it slides away. Kept on death (user request, 2026-10-22). */ + /** Kick: walk into a bomb and it slides away. */ kick: boolean - /** Throw: a bomb 2 tiles away in a straight line, over anything. Kept on death. */ + /** Throw: a bomb 2 tiles away in a straight line, over anything. */ throw: boolean - /** Teleport: move to any free tile up to TELEPORT_RANGE steps away, over anything. Kept on death. */ + /** Teleport: move to any free tile up to TELEPORT_RANGE steps away, over anything. */ teleport: boolean - /** Detonator: the Detonate action, set off one of your bombs now. Lost on death. */ + /** Diagonal: Move can also step diagonally (a charge until 2026-10-23, user request). */ + diagonal: boolean + /** Detonator: the Detonate action, set off one of your bombs now. */ detonate: boolean - /** Walk through soft blocks. Lost on death. */ + /** Walk through soft blocks. */ wallPass: boolean - /** Walk over bombs. Lost on death. */ + /** Walk over bombs. */ bombPass: boolean - /** Flame doesn't hurt. Lost on death. */ + /** Flame doesn't hurt. */ flamePass: boolean } @@ -117,21 +130,32 @@ export interface Enemy { plan: Vec[] /** Spawned as a punishment (the exit was flamed): drops nothing. */ spawned: boolean + /** + * Just let out by a flamed exit or gem: the flame that let it out (still lingering next turn) + * can't hurt it; any new explosion can (user's choice, 2026-10-23: they used to die in it at once). + */ + sheltered: boolean } /** - * 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. + * The charges dropped by enemies (PLAN.md §3.3). Since 2026-10-23 (user request) they aren't + * actions: each works the moment it is picked up, as a temporary change (`applyCharge`). + * Pierce, Power and Nuke change the next BOMB_BOOST bombs; Shield blocks the next hit; Freeze + * freezes every enemy; Decoy leaves a copy of the player on the pickup tile. (Line was a charge + * until then, and Diagonal, which became a power-up the same day; Kick, Throw and Teleport until + * 2026-10-22; Fuse ±1 was removed on 2026-10-22.) */ -export type ChargeId = 'shield' | 'freeze' | 'diagonal' | 'pierce' | 'power' | 'line' | 'decoy' +export type ChargeId = 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy' -export const CHARGE_IDS: readonly ChargeId[] = ['shield', 'freeze', 'diagonal', 'pierce', 'power', 'line', 'decoy'] +export const CHARGE_IDS: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy'] -/** - * 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 +/** What picked-up charges still have to give. Kept from level to level and through deaths. */ +export interface Boosts { + /** How many of the player's next bombs pierce, reach the walls, nuke. Each bomb placed uses one of each. */ + pierce: number + power: number + nuke: number +} /** A stand-in for the player that chasing enemies go after instead, for a few turns. */ export interface Decoy { @@ -166,8 +190,8 @@ export interface GameState { /** Finishing the level in at most this many turns pays PAR_BONUS coins. */ par: number coins: number - charges: Record<ChargeId, number> - /** Shield armed: the next hit (flame or enemy) is absorbed. */ + boosts: Boosts + /** Shield armed (picked up): the next hit (flame or enemy) is absorbed. Kept to the next level. */ shield: boolean /** Freeze: how many more enemy moves are skipped (FREEZE_TURNS when used; the turn it is used counts). */ frozen: number @@ -177,8 +201,13 @@ export interface GameState { deaths: number /** Turns of Star invincibility left. */ star: number - /** This level's power-up: hidden while its block stands, then lying in the open until taken. Null once taken or burned. */ + /** This level's gem: hidden while its block stands, then lying in the open until taken. Null once taken or burned. */ powerUp: PowerUp | null + /** + * The power-ups the gem just taken offers. While it is set, the only action is `choose`; a + * death (or game over) in the same turn drops it, and the retry has the gem back. + */ + offer: PowerUpKind[] | null /** * Tile indices still on fire from last turn's explosions. Flame lingers one extra turn: it * kills creatures that are in it or walk into it this turn, and the player can't step or put @@ -194,9 +223,10 @@ export interface GameState { } /** The actions the player can pick on the board. */ -export type ActionId = 'move' | 'bomb' | 'wait' | 'run' | 'detonate' | 'kick' | 'throw' | 'teleport' | ChargeActionId +export type ActionId = 'move' | 'bomb' | 'wait' | 'run' | 'detonate' | 'kick' | 'throw' | 'teleport' | 'diagonal' -export type Action = { id: ActionId; target?: Vec } | { id: 'continue' } +/** `choose`: one of the gem's offer. It takes no turn. */ +export type Action = { id: ActionId; target?: Vec } | { id: 'continue' } | { id: 'choose'; kind: PowerUpKind } /** What `step()` reports happened, in order, for the renderer to play back. */ export type GameEvent = @@ -218,14 +248,14 @@ export type GameEvent = | { type: 'exitOpened'; pos: Vec } | { type: 'levelCleared'; level: number; turns: number; parBonus: number } | { type: 'levelStarted'; level: number } - | { type: 'chargeUsed'; id: ChargeId } | { type: 'bombKicked'; from: Vec; to: Vec } | { type: 'shieldBroke'; pos: Vec } | { type: 'decoyPlaced'; pos: Vec } | { type: 'decoyGone'; pos: Vec; burned: boolean } | { type: 'powerUpRevealed'; powerUp: PowerUp } | { type: 'powerUpBurned'; powerUp: PowerUp } - | { type: 'powerUpTaken'; powerUp: PowerUp } + | { type: 'powerUpTaken'; powerUp: PowerUp; offer: PowerUpKind[] } + | { type: 'powerUpChosen'; kind: PowerUpKind } | { type: 'detonated'; pos: Vec } | { type: 'runWon'; turns: number; deaths: number } | { type: 'cratesCrumbling'; count: number } diff --git a/src/style.css b/src/style.css index 20dfdc9..25e81dc 100644 --- a/src/style.css +++ b/src/style.css @@ -51,16 +51,53 @@ img { color: #fff; } -/* 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. */ +/* The items sit in a fixed number of rows that never wrap (user request, 2026-10-23: the bar + grew a row as power-ups and charges came, and the board moved down). On a phone or a narrow + screen: three rows, the third (passes, Star, charges) kept even while empty. On a wide screen: + one row. A row whose content is too wide scrolls sideways instead of growing. 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; + flex-direction: column; + gap: 4px; +} + +.hud-row { + display: flex; + flex-wrap: nowrap; justify-content: space-between; align-items: center; - gap: 4px 12px; + gap: 12px; + min-height: 30px; + overflow-x: auto; + scrollbar-width: none; +} + +.hud-row::-webkit-scrollbar { + display: none; +} + +#hud-third { + justify-content: flex-start; +} + +@media (min-width: 900px) { + #hud-items { + flex-direction: row; + align-items: center; + gap: 12px; + } + + .hud-row { + flex: none; + } + + #hud-third { + flex: 1 1 0; + min-width: 0; + } } /* On a phone the menu sits above "?" (user request, 2026-10-22); in the HTML "?" comes first, @@ -78,10 +115,9 @@ img { } } -#hud-items > span { +.hud-row > span { display: flex; - /* A long row (many passes held) wraps instead of pushing the buttons off the screen. */ - flex-wrap: wrap; + flex: none; align-items: center; } @@ -191,39 +227,6 @@ img { display: none; } -/* 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; - padding: 2px 2px 4px; - font-size: 11px; -} - -#actions > button.charge .icon { - width: 28px; - height: 28px; -} - -#actions > button { - position: relative; -} - -#actions .count { - position: absolute; - top: -12px; - right: -6px; - min-width: 20px; - padding: 0 4px; - border: 2px solid var(--ink); - border-radius: 11px; - background: #ffc83d; - color: var(--ink); - font-size: 12px; - font-weight: 800; -} - /* On a phone there is no keyboard to hint at. */ @media (max-width: 600px) { #actions kbd { @@ -231,73 +234,6 @@ img { } } -/* 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; -} - -#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; @@ -315,8 +251,7 @@ img { } /* One-colour ink icons (Move, Wait) turn white on the green of a picked button. */ -#actions > button[aria-pressed='true'] .mono, -#explain .mono { +#actions > button[aria-pressed='true'] .mono { filter: brightness(0) invert(1); } @@ -603,6 +538,15 @@ img { color: inherit; } +/* A dialog answered from its body (the gem's choice) has no buttons below: more room for it. */ +#dialog .panel.required #dialog-body { + max-height: calc(100vh - 150px); +} + +#dialog-buttons:empty { + display: none; +} + /* The almanac (ui/almanac.ts): tabs, then a picture and the words for each thing. */ .almanac-tabs { position: sticky; @@ -656,6 +600,25 @@ img { font-size: 14px; } +/* The gem's choice (ui/offer.ts): each power-up is a card to tap, laid out like an almanac entry. */ +.offer-choice { + width: 100%; + margin: 0 0 8px; + padding: 8px 10px; + border: 2px solid var(--ink); + border-radius: 12px; + background: #fff8e6; + box-shadow: 0 3px 0 rgb(53 53 65 / 0.35); + color: var(--ink); + font: inherit; + cursor: pointer; +} + +.offer-choice:active { + transform: translateY(2px); + box-shadow: none; +} + /* Keyboard focus in the dialog: a yellow ring, like the count badges. */ #dialog button:focus-visible { outline: 3px solid #ffc83d; @@ -754,3 +717,90 @@ img { #dialog-body::-webkit-scrollbar { display: none; } + +/* The demo before the game (main.ts): the board alone, a caption panel at the bottom. */ +body.demo #hud, +body.demo #actions { + display: none; +} + +#demo { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 2; + padding: 12px 16px calc(env(safe-area-inset-bottom) + 16px); + background: var(--ink); + color: #fff; + text-align: center; +} + +#demo[hidden] { + display: none; +} + +#demo h1 { + margin: 0 0 6px; + font: 24px/1.2 'Kenney Future', ui-rounded, system-ui, sans-serif; + letter-spacing: 0.04em; +} + +/* The words, with a scene's icon beside them (not in the line, which would space it unevenly). */ +#demo-caption { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 3.9em; + margin: 0; + font-size: 17px; + line-height: 1.3; +} + +/* A scene about one charge or power-up shows its icon before the words. */ +#demo .demo-icon { + flex: none; + width: 34px; + height: 34px; + /* A light rim, so a dark sprite (a bomb) stands out on the dark panel, as in the top bar. */ + filter: drop-shadow(0 0 1.5px rgb(255 255 255 / 0.7)); +} + +/* One-colour ink icons (the padlock) turn white on the dark panel. */ +#demo .demo-icon.mono { + filter: brightness(0) invert(1); +} + +#demo .demo-hint { + margin: 10px 0 0; + color: #ffc83d; + font-weight: 800; + animation: demo-hint 1.6s ease-in-out infinite; +} + +@keyframes demo-hint { + 50% { + opacity: 0.45; + } +} + +@media (prefers-reduced-motion: reduce) { + /* A scene about one charge or power-up shows its icon before the words. */ +#demo .demo-icon { + flex: none; + width: 34px; + height: 34px; + /* A light rim, so a dark sprite (a bomb) stands out on the dark panel, as in the top bar. */ + filter: drop-shadow(0 0 1.5px rgb(255 255 255 / 0.7)); +} + +/* One-colour ink icons (the padlock) turn white on the dark panel. */ +#demo .demo-icon.mono { + filter: brightness(0) invert(1); +} + +#demo .demo-hint { + animation: none; + } +} diff --git a/src/ui/almanac.ts b/src/ui/almanac.ts index ca1ab6c..7db9b39 100644 --- a/src/ui/almanac.ts +++ b/src/ui/almanac.ts @@ -1,4 +1,5 @@ import { enemyKinds } from '../sim/levelgen' +import { OFFER_SIZE } from '../sim/turn' 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' @@ -18,7 +19,7 @@ const TABS: [AlmanacTab, string][] = [ /** 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', + 'bombPlus', 'firePlus', 'fusePlus', 'run', 'kick', 'throw', 'teleport', 'diagonal', 'detonator', 'wallPass', 'bombPass', 'flamePass', 'star', 'heart', 'coinBag', ] @@ -43,11 +44,11 @@ function body(tab: AlmanacTab): string { 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>' + + `<p class="almanac-intro">Every level hides a gem under a crate. Burn the crate and walk onto the gem to choose one of ${OFFER_SIZE} of these.</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>' + + '<p class="almanac-intro">Enemies sometimes drop these. Each works as soon as you pick it up; the top bar shows what it still has to give.</p>' + CHARGE_IDS.map((id) => entry(CHARGE_ICONS[id], CHARGE_TEXT[id].name, [CHARGE_TEXT[id].text])).join('') } else { list = diff --git a/src/ui/controls.ts b/src/ui/controls.ts index c1e26b0..33c6c8a 100644 --- a/src/ui/controls.ts +++ b/src/ui/controls.ts @@ -1,9 +1,8 @@ -import { ACTIONS } from '../sim/actions' import { canPick } from '../sim/picks' import { PAR_BONUS, START_LIVES } from '../sim/state' -import type { ActionId, ChargeId, GameState, PowerUpKind, Powers } from '../sim/types' +import { CHARGE_IDS, type ActionId, type GameState, type PowerUpKind, type Powers } from '../sim/types' import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites' -import { actionInfo, type HudItem } from './info' +import { CHARGE_TEXT, chargeLeft, 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. */ @@ -13,8 +12,6 @@ export interface Handlers { * `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. */ @@ -27,9 +24,8 @@ export interface Handlers { /** * 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. + * throws and detonates (sim/picks.ts). Charges have no buttons: they work when picked up, and + * what they still give shows in the top bar (user request, 2026-10-23). */ interface ButtonDef { id: ActionId @@ -38,37 +34,26 @@ interface ButtonDef { 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: 'bomb', label: 'Bomb', icon: 'bomb', key: 'B', group: ['bomb', 'throw', 'detonate'] }, { 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', 'run', 'Run'], ['kick', 'kick', 'Kick'], ['throw', 'throw', 'Throw'], ['teleport', 'teleport', 'Teleport'], + ['diagonal', 'diagonal', 'Diagonal'], ['detonate', 'detonator', 'Detonator'], ['wallPass', 'wallPass', 'Wall Pass'], ['bombPass', 'bombPass', 'Bomb Pass'], ['flamePass', 'flamePass', 'Flame Pass'], ] -/** 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 { @@ -92,7 +77,12 @@ function show(slot: HTMLElement, html: string): void { const kept = old.get(was) if (kept) return kept ;(c as HTMLElement).dataset.was = was - if (!first) c.classList.add('bump') + if (!first) { + c.classList.add('bump') + // Off again once it has played: every later update re-attaches the kept items, which + // would otherwise start the bump over each time. + c.addEventListener('animationend', () => c.classList.remove('bump'), { once: true }) + } return c }), ) @@ -117,10 +107,7 @@ export function createControls(handlers: Handlers) { 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)) @@ -129,45 +116,9 @@ export function createControls(handlers: Handlers) { 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?": 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">` @@ -193,6 +144,7 @@ export function createControls(handlers: Handlers) { enemies: document.getElementById('hud-enemies')!, coins: document.getElementById('hud-coins')!, stats: document.getElementById('hud-stats')!, + extras: document.getElementById('hud-extras')!, modes: document.getElementById('hud-modes')!, } @@ -207,23 +159,14 @@ export function createControls(handlers: Handlers) { 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. + // Auto-move: Move has become Go, one step toward the destination per press. It keeps + // the walk icon (user request, 2026-10-23: it used to turn into a flag); a green + // border says it will walk. 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) } - 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[def.charge]}` - } } - 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) => icon(i < s.lives ? 'hud_heart' : 'hud_heart_empty'), @@ -250,21 +193,25 @@ export function createControls(handlers: Handlers) { .map(([p, kind, name]) => item(p, `<span class="sr-only">${name}</span>${icon(POWER_UP_ICONS[kind], 'icon small')}`)) .join('') const star = s.star > 0 ? item('star', icon('star', 'icon small') + number(s.star, 'Star for ')) : '' + // What picked-up charges still give, each with its count (bombs, levels or turns left). + const charges = CHARGE_IDS.map((id) => { + const left = chargeLeft(s, id) + if (!left) return '' + const name = CHARGE_TEXT[id].name + return item(id, icon(CHARGE_ICONS[id], 'icon small') + (left === true ? `<span class="sr-only">${name}</span>` : number(left, `${name} `))) + }).join('') show( hud.stats, item('bombs', icon('bomb') + number(s.stats.capacity, 'Bombs ')) + item('fire', icon('fireball') + number(s.stats.fire, 'Blast ')) + - item('fuse', icon('icon_fuse') + number(s.stats.fuse, 'Fuse ')) + - passes + - star, + item('fuse', icon('icon_fuse') + number(s.stats.fuse, 'Fuse ')), ) + // Their own row, which keeps its place even while empty, so the board never moves. + show(hud.extras, passes + star + charges) banner.hidden = s.status === 'playing' if (s.status === 'dead') { - const lost = LOST_ON_DEATH.filter(([p]) => s.powers[p]).map(([, , name]) => name) - if (s.star > 0) lost.push('Star') - bannerText.textContent = - `Ouch! ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.` + (lost.length ? ` Lost: ${lost.join(', ')}.` : '') + bannerText.textContent = `Ouch! ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.` bannerButton.textContent = 'Try again' bannerIcon.src = spriteUrl('character_green_hit') } else if (s.status === 'gameOver') { diff --git a/src/ui/dialog.ts b/src/ui/dialog.ts index f37f2af..34f808c 100644 --- a/src/ui/dialog.ts +++ b/src/ui/dialog.ts @@ -21,6 +21,8 @@ export interface DialogContent { /** Trusted HTML built by the game itself (never from user input). */ body: string buttons: DialogButton[] + /** Must be answered from its body: no close button, and neither Esc nor the backdrop closes it. */ + required?: boolean } const dialog = document.getElementById('dialog') as HTMLDialogElement @@ -33,9 +35,10 @@ const panel = dialog.querySelector<HTMLElement>('.panel')! /** How long the panel takes to shrink away and the backdrop to clear (style.css `.closing`). */ const CLOSE_MS = 150 let closing: { timer: number; then?: () => void } | null = null +let required = false /** Close with a short shrink-and-fade, then run `then` (a button's action). */ -function closeDialog(then?: () => void): void { +export function closeDialog(then?: () => void): void { if (!dialog.open || closing) return if (matchMedia('(prefers-reduced-motion: reduce)').matches) { dialog.close() @@ -56,10 +59,11 @@ function finishClosing(): void { then?.() } -document.getElementById('dialog-close')!.addEventListener('click', () => closeDialog()) +const closeButton = document.getElementById('dialog-close')! +closeButton.addEventListener('click', () => closeDialog()) // A tap on the dimmed backdrop (the dialog element itself, outside its panel) closes it. dialog.addEventListener('click', (e) => { - if (e.target === dialog) closeDialog() + if (e.target === dialog && !required) 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. @@ -67,7 +71,7 @@ dialog.addEventListener('wheel', (e) => e.stopPropagation(), { passive: true }) // Esc closes it too, animated like the rest. dialog.addEventListener('cancel', (e) => { e.preventDefault() - closeDialog() + if (!required) closeDialog() }) export function openDialog(content: DialogContent): void { @@ -75,6 +79,9 @@ export function openDialog(content: DialogContent): void { finishClosing() icon.hidden = !content.icon if (content.icon) icon.src = content.icon + required = !!content.required + closeButton.hidden = required + panel.classList.toggle('required', required) title.textContent = content.title body.innerHTML = content.body buttons.replaceChildren( diff --git a/src/ui/info.ts b/src/ui/info.ts index 5934f77..c7466ea 100644 --- a/src/ui/info.ts +++ b/src/ui/info.ts @@ -1,10 +1,10 @@ -import { DECOY_TURNS, FREEZE_TURNS, TELEPORT_RANGE } from '../sim/actions' +import { BOMB_BOOST, DECOY_TURNS, FREEZE_TURNS, NUKE_RANGE, 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 { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS } from '../sim/turn' 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' +import { CHARGE_ICONS, coinSprite, ENEMY_SPRITES, GEM_SPRITES, isOuterWall, POWER_UP_ICONS, spriteUrl, wallSprite } from '../render/sprites' /** * What the game says about each thing on the board and in the top bar, for the info dialog @@ -21,53 +21,73 @@ export interface Info { export const ENEMY_TEXT: Record<EnemyKind, { name: string; moves: string }> = { blob: { name: 'Blob', moves: 'Wanders, usually carrying on the way it was going.' }, seeker: { name: 'Seeker', moves: 'Half the time it takes the shortest way toward you (or your decoy); otherwise it wanders like a Blob.' }, - drifter: { name: 'Drifter', moves: 'Goes straight until something blocks it, then turns.' }, + drifter: { name: 'Drifter', moves: 'Goes straight until something blocks it (or another enemy is heading there), then turns.' }, } export const POWER_UP_TEXT: Record<PowerUpKind, { name: string; text: string }> = { bombPlus: { name: 'Bomb +1', text: 'You can have one more bomb on the board at a time.' }, firePlus: { name: 'Fire +1', text: 'Your blasts reach one tile further in each direction.' }, fusePlus: { name: 'Fuse +1', text: 'Your bombs start one higher: one more turn to get away.' }, - 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.' }, + run: { name: 'Run', text: 'Move can also run: 2 tiles in a straight line in one turn. Both tiles must be empty.' }, + detonator: { name: 'Detonator', text: 'Your bombs pulse: tap one and it goes off this turn.' }, + wallPass: { name: 'Wall Pass', text: 'You can walk through crates.' }, + bombPass: { name: 'Bomb Pass', text: 'You can walk over bombs.' }, + flamePass: { name: 'Flame Pass', text: 'Flame can’t hurt you (enemies still can).' }, 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.` }, + kick: { name: 'Kick', text: 'Walk into a bomb next to you and it slides away until something stops it, keeping its number.' }, + throw: { name: 'Throw', text: 'Bomb can also put a bomb 2 tiles away in a straight line, over anything in between.' }, + teleport: { name: 'Teleport', text: `Move can also take you to any empty tile up to ${TELEPORT_RANGE} steps away, over anything.` }, + diagonal: { name: 'Diagonal', text: 'Move can also step diagonally, even squeezing between two blocked tiles.' }, } +/** What each charge does once picked up (user request, 2026-10-23: they work at once, as a temporary change). */ export const CHARGE_TEXT: Record<ChargeId, { name: string; text: string }> = { - 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 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.` }, + pierce: { name: 'Pierce', text: `Your next ${BOMB_BOOST} bombs’ flame goes through crates, burning every one in reach.` }, + power: { name: 'Power', text: `Your next ${BOMB_BOOST} bombs’ flame reaches all the way to the walls (crates still stop it).` }, + nuke: { name: 'Nuke', text: `Your next ${BOMB_BOOST} bombs also burn every tile within ${NUKE_RANGE} steps, through crates and round corners. Get well away!` }, + shield: { name: 'Shield', text: 'The next hit (flame or enemy) is absorbed, and anything else that hits you that turn. Then the shield is gone.' }, + freeze: { name: 'Freeze', text: `Every enemy freezes in place for ${FREEZE_TURNS} turns, this one included, thawing as it goes.` }, + decoy: { name: 'Decoy', text: `Leaves a copy of you where you picked it up. Seekers chase it instead of you for ${DECOY_TURNS} turns. Flame destroys it.` }, +} + +/** + * How much a charge still has to give, for the top bar: bombs or turns left, true for one + * without a count (Shield), nothing once it is spent. + */ +export function chargeLeft(s: GameState, id: ChargeId): number | true | 0 { + switch (id) { + case 'pierce': + case 'power': + case 'nuke': + return s.boosts[id] + case 'shield': + return s.shield || 0 + case 'freeze': + return s.frozen + case 'decoy': + return s.decoy?.turns ?? 0 + } } const ARROWS: Record<Dir, string> = { up: '↑', down: '↓', left: '←', right: '→' } const PUNISH = `Don’t blast it once it’s uncovered: flame on it lets out ${EXIT_SPAWN} more enemies.` /** The top bar's items. */ -export type HudItem = 'modes' | 'lives' | 'level' | 'enemies' | 'coins' | 'turn' | 'bombs' | 'fire' | 'fuse' | 'star' | keyof Powers +export type HudItem = 'modes' | 'lives' | 'level' | 'enemies' | 'coins' | 'turn' | 'bombs' | 'fire' | 'fuse' | 'star' | keyof Powers | ChargeId /** One line per setting of the run that differs from a plain normal run. */ export function modeLines(s: GameState): string[] { const lines = [s.options.size === 'small' ? `Small board: ${s.width} × ${s.height}, ${lastLevel('small')} levels, fewer enemies and crates.` : `Normal board: ${s.width} × ${s.height}, ${lastLevel('normal')} levels.`] - if (s.options.dev) lines.push('Developer mode: every power-up and ability, unlimited action charges, 5 bombs, blast 5. You can still be killed.') + if (s.options.dev) lines.push('Developer mode: every level’s gem lies uncovered right next to you at the start.') if (s.options.god) lines.push('God mode: nothing can kill you.') if (s.options.crumble) lines.push('Crumbling crates: once the last enemy is gone, every crate crumbles away over 1–3 turns.') return lines } const PASS_KIND: Record<keyof Powers, PowerUpKind> = { - run: 'run', kick: 'kick', throw: 'throw', teleport: 'teleport', detonate: 'detonator', wallPass: 'wallPass', + run: 'run', kick: 'kick', throw: 'throw', teleport: 'teleport', diagonal: 'diagonal', detonate: 'detonator', wallPass: 'wallPass', bombPass: 'bombPass', flamePass: 'flamePass', } @@ -128,6 +148,13 @@ export function hudInfo(item: HudItem, s: GameState): Info { } case 'star': return { icon: 'star', title: `Star: ${s.star} turns left`, body: ['Nothing can hurt you until it runs out.'] } + case 'pierce': + case 'power': + case 'nuke': + case 'shield': + case 'freeze': + case 'decoy': + return { icon: CHARGE_ICONS[item], title: chargeTitle(s, item), body: [CHARGE_TEXT[item].text] } default: { const kind = PASS_KIND[item] return { icon: POWER_UP_ICONS[kind], title: POWER_UP_TEXT[kind].name, body: [POWER_UP_TEXT[kind].text] } @@ -159,16 +186,14 @@ export function tileInfo(s: GameState, p: Vec): Info | null { } const bomb = bombAt(s, p) if (bomb) { - const kind = bomb.special === 'pierce' ? 'Pierce bomb' : bomb.special === 'power' ? 'Power bomb' : 'Bomb' - const reach = bomb.special === 'power' ? 'to the walls' : `${bomb.fire} ${bomb.fire === 1 ? 'tile' : 'tiles'}` - return { - icon: 'bomb', - title: `${kind}: ${bomb.timer}`, - body: [ - `Explodes at the end of your ${bomb.timer === 1 ? 'next action' : `${bomb.timer}th action from now`}.`, - `Its blast reaches ${reach} in each direction. Flame stops at walls${bomb.special === 'pierce' ? ' (it goes through crates)' : ' and on the first crate'}, and sets off any bomb it reaches.`, - ], - } + const kinds = (['pierce', 'power', 'nuke'] as const).filter((k) => bomb[k]).map((k) => CHARGE_TEXT[k].name) + const reach = bomb.power ? 'to the walls' : `${bomb.fire} ${bomb.fire === 1 ? 'tile' : 'tiles'}` + const body = [ + `Explodes at the end of your ${bomb.timer === 1 ? 'next action' : `${bomb.timer}th action from now`}.`, + `Its blast reaches ${reach} in each direction. Flame stops at walls${bomb.pierce ? ' (it goes through crates)' : ' and on the first crate'}, and sets off any bomb it reaches.`, + ] + if (bomb.nuke) body.push(`Nuke: it also burns every tile within ${NUKE_RANGE} steps, through crates and round corners.`) + return { icon: 'bomb', title: `${kinds.length ? `${kinds.join(' + ')} bomb` : 'Bomb'}: ${bomb.timer}`, body } } if (s.decoy && eq(s.decoy.pos, p)) { return { icon: 'character_green_front', title: 'Decoy', body: [`Seekers chase it instead of you for ${s.decoy.turns} more ${s.decoy.turns === 1 ? 'turn' : 'turns'}. Flame destroys it.`] } @@ -194,18 +219,21 @@ export function tileInfo(s: GameState, p: Vec): Info | null { return { icon: 'block_planks', title: 'Crate', - body: ['Flame destroys it (and stops there). On every level one crate hides the exit and another hides a power-up.'], + body: ['Flame destroys it (and stops there). On every level one crate hides the exit and another hides a gem.'], } } if (s.powerUp && eq(s.powerUp.pos, p)) { - const t = POWER_UP_TEXT[s.powerUp.kind] - return { icon: POWER_UP_ICONS[s.powerUp.kind], title: t.name, body: [t.text, 'Walk onto it to take it. ' + PUNISH] } + return { + icon: GEM_SPRITES[s.powerUp.color], + title: 'Gem', + body: [`Walk onto it to choose one of ${OFFER_SIZE} power-ups. The colour doesn’t matter.`, PUNISH], + } } if (eq(s.exit, p)) { return exitOpen(s) - ? { icon: 'door_open', title: 'Exit: open', body: ['Step onto it to finish the level.', PUNISH] } + ? { icon: 'door_open_top', title: 'Exit: open', body: ['Step onto it to finish the level.', PUNISH] } : { - icon: 'door_closed', + icon: 'door_closed_top', title: 'Exit: locked', body: [`It opens when every enemy is gone (${s.enemies.length} left). Then step onto it to finish the level.`, PUNISH], } @@ -214,21 +242,24 @@ export function tileInfo(s: GameState, p: Vec): Info | null { if (drop) { if (drop.kind === 'coins') return { icon: coinSprite(drop.amount), title: `${drop.amount} ${drop.amount === 1 ? 'coin' : 'coins'}`, body: ['Walk onto it to take it. A blast destroys it.'] } const t = CHARGE_TEXT[drop.kind] - return { icon: CHARGE_ICONS[drop.kind], title: `${t.name} charge`, body: [t.text, 'Walk onto it to take one. Using it takes your turn. A blast destroys it.'] } + return { icon: CHARGE_ICONS[drop.kind], title: `${t.name} charge`, body: [t.text, 'Walk onto it: it works as soon as you pick it up. A blast destroys it.'] } } 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] } -} - -/** Shown when a kind of action charge is picked up for the first time in a run (user request, 2026-10-22). */ +/** Shown when a kind of charge is picked up for the first time in a run (user request, 2026-10-22). */ export function chargeInfo(kind: ChargeId): Info { const t = CHARGE_TEXT[kind] - return { icon: CHARGE_ICONS[kind], title: `${t.name} charge`, body: ['You picked it up!', t.text, 'Each charge is used once, and using it takes your turn.'] } + return { icon: CHARGE_ICONS[kind], title: `${t.name} charge`, body: ['You picked it up! It works right away.', t.text, 'The top bar shows what it still has to give.'] } +} + +/** A charge in the top bar, with what it has left. */ +function chargeTitle(s: GameState, id: ChargeId): string { + const name = CHARGE_TEXT[id].name + const left = chargeLeft(s, id) + if (left === true) return name + if (id === 'pierce' || id === 'power' || id === 'nuke') return `${name}: next ${left} ${left === 1 ? 'bomb' : 'bombs'}` + return `${name}: ${turns(left)} left` } /** `n` turns, in words. */ @@ -236,11 +267,6 @@ 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 @@ -258,7 +284,7 @@ export function actionInfo(s: GameState, picked: ActionId): Info { 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.`) + if (s.powers.diagonal) body.push('Diagonal: a diagonal step, even between two blocked tiles.') return { icon: 'icon_move', title: 'Move', body } } case 'bomb': @@ -269,22 +295,13 @@ export function actionInfo(s: GameState, picked: ActionId): Info { ] 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.') + for (const k of ['pierce', 'power', 'nuke'] as const) { + if (s.boosts[k] > 0) body.push(`${CHARGE_TEXT[k].name}: your next ${s.boosts[k]} ${s.boosts[k] === 1 ? 'bomb' : 'bombs'}.`) + } 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] } - } } } diff --git a/src/ui/offer.ts b/src/ui/offer.ts new file mode 100644 index 0000000..fc4ae9e --- /dev/null +++ b/src/ui/offer.ts @@ -0,0 +1,36 @@ +import type { PowerUpKind } from '../sim/types' +import { POWER_UP_ICONS, spriteUrl } from '../render/sprites' +import { closeDialog, openDialog } from './dialog' +import { POWER_UP_TEXT } from './info' + +/** + * The gem's choice (user request, 2026-10-23): its offer of power-ups, each with what it does, + * laid out like the almanac. One must be picked; the dialog can't be closed any other way. + */ +let chosen: ((kind: PowerUpKind) => void) | null = null + +export function openOffer(offer: PowerUpKind[], choose: (kind: PowerUpKind) => void): void { + chosen = choose + openDialog({ + title: 'Choose a power-up', + body: offer + .map( + (k) => + `<button type="button" class="almanac-entry offer-choice" data-offer="${k}">` + + `<img src="${spriteUrl(POWER_UP_ICONS[k])}" alt="" draggable="false">` + + `<div><strong>${POWER_UP_TEXT[k].name}</strong><p>${POWER_UP_TEXT[k].text}</p></div></button>`, + ) + .join(''), + buttons: [], + required: true, + }) +} + +const dialogBody = document.getElementById('dialog-body')! +dialogBody.addEventListener('click', (e) => { + const button = (e.target as Element).closest<HTMLElement>('[data-offer]') + if (!button || !chosen) return + const choose = chosen + chosen = null + closeDialog(() => choose(button.dataset.offer as PowerUpKind)) +}) diff --git a/static/icons/apple-touch-icon.png b/static/icons/apple-touch-icon.png new file mode 100644 index 0000000..8fb4eba Binary files /dev/null and b/static/icons/apple-touch-icon.png differ diff --git a/static/icons/icon-192.png b/static/icons/icon-192.png new file mode 100644 index 0000000..23bc00e Binary files /dev/null and b/static/icons/icon-192.png differ diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png new file mode 100644 index 0000000..1b6912f Binary files /dev/null and b/static/icons/icon-512.png differ diff --git a/static/icons/icon-maskable-512.png b/static/icons/icon-maskable-512.png new file mode 100644 index 0000000..5c4ad21 Binary files /dev/null and b/static/icons/icon-maskable-512.png differ diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest new file mode 100644 index 0000000..eb91195 --- /dev/null +++ b/static/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "Explodorama", + "short_name": "Explodorama", + "description": "A turn-based, mobile-first, roguelike bomb-laying game. Every bomb counts down with your moves.", + "start_url": "./", + "scope": "./", + "display": "fullscreen", + "orientation": "any", + "background_color": "#c3e3ff", + "theme_color": "#353541", + "icons": [ + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..79ec3fe --- /dev/null +++ b/static/sw.js @@ -0,0 +1,26 @@ +/** + * The service worker that makes the game installable and playable offline (user request, + * 2026-10-23). Network first: online, every request goes to the network as usual (so a new + * deploy is picked up at once) and a copy is kept; offline, the copy is used. Only this site's + * own GET requests are touched. Registered by main.ts in the built game only. + */ +const CACHE = 'explodorama' + +self.addEventListener('install', () => self.skipWaiting()) +self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim())) + +self.addEventListener('fetch', (e) => { + const request = e.request + if (request.method !== 'GET' || new URL(request.url).origin !== self.location.origin) return + e.respondWith( + fetch(request) + .then((response) => { + if (response.ok) { + const copy = response.clone() + e.waitUntil(caches.open(CACHE).then((cache) => cache.put(request, copy))) + } + return response + }) + .catch(() => caches.match(request).then((cached) => cached ?? Response.error())), + ) +}) diff --git a/test/charges.test.ts b/test/charges.test.ts index 43da85d..38945df 100644 --- a/test/charges.test.ts +++ b/test/charges.test.ts @@ -1,20 +1,87 @@ import { describe, expect, it } from 'vitest' -import { ACTIONS, DECOY_TURNS, TELEPORT_RANGE } from '../src/sim/actions' +import { ACTIONS, BOMB_BOOST, DECOY_TURNS, FREEZE_TURNS, isAvailable, NUKE_RANGE, TELEPORT_RANGE } from '../src/sim/actions' import { stepToward } from '../src/sim/enemies' import { blastTiles } from '../src/sim/explosions' +import { carryOf, createLevel } from '../src/sim/state' import { step } from '../src/sim/turn' +import type { ChargeId, GameState } from '../src/sim/types' import { board, v } from './helpers' +/** The state after walking right onto a charge of `kind` lying next to the player. */ +function pickUp(s: GameState, kind: ChargeId): GameState { + const at = v(s.player.x + 1, s.player.y) + s.drops.push({ pos: at, kind }) + return step(s, { id: 'move', target: at }).state +} + +describe('charges work when picked up', () => { + it('Pierce, Power and Nuke each give the next bombs that boost', () => { + for (const kind of ['pierce', 'power', 'nuke'] as const) { + const s = pickUp(board(` + ###### + #P...# + ######`), kind) + expect(s.boosts[kind]).toBe(BOMB_BOOST) + expect(pickUp(s, kind).boosts[kind]).toBe(2 * BOMB_BOOST) + } + }) + + it('Shield is armed at once', () => { + expect(pickUp(board(` + ##### + #P..# + #####`), 'shield').shield).toBe(true) + }) + + it(`Freeze freezes every enemy for ${FREEZE_TURNS} turns, the pickup turn included`, () => { + const s = board(` + ####### + #P...B# + #######`) + s.enemies[0]!.plan = [v(4, 1), v(3, 1), v(2, 1)] + let after = pickUp(s, 'freeze') + expect(after.enemies[0]!.pos).toEqual(v(5, 1)) + for (let t = 1; t < FREEZE_TURNS; t++) after = step(after, { id: 'wait' }).state + expect(after.enemies[0]!.pos).toEqual(v(5, 1)) + expect(after.frozen).toBe(0) + }) + + it('Decoy leaves a copy on the pickup tile, which chasing enemies head for', () => { + const s = board(` + ######## + #P....S# + ########`) + const { state, events } = step({ ...s, drops: [{ pos: v(2, 1), kind: 'decoy' }] }, { id: 'move', target: v(2, 1) }) + expect(events).toContainEqual({ type: 'decoyPlaced', pos: v(2, 1) }) + expect(state.decoy).toEqual({ pos: v(2, 1), turns: DECOY_TURNS - 1 }) + const away = step(state, { id: 'move', target: v(1, 1) }).state + expect(stepToward(away, away.enemies[0]!)?.to.x).toBeLessThan(away.enemies[0]!.pos.x) + }) + + it('what they still give goes on to the next level, and through a death', () => { + const s = pickUp(pickUp(board(` + ###### + #P...# + ######`), 'pierce'), 'shield') + const next = createLevel(s.seed, 2, s.options, carryOf(s)) + expect(next.boosts.pierce).toBe(BOMB_BOOST) + expect(next.shield).toBe(true) + expect(createLevel(s.seed, 1, s.options, carryOf(s)).boosts.pierce).toBe(BOMB_BOOST) + }) +}) + describe('Diagonal', () => { - it('squeezes between two blocked tiles', () => { + it('is a power-up: squeezes between two blocked tiles once gained, kept through a death', () => { const s = board(` ##### #P+.# #+..# #####`) - s.charges.diagonal = 1 + expect(isAvailable(s, 'diagonal')).toBe(false) + s.powers.diagonal = true expect(ACTIONS.diagonal.validTargets(s)).toEqual([v(2, 2)]) expect(step(s, { id: 'diagonal', target: v(2, 2) }).state.player).toEqual(v(2, 2)) + expect(carryOf(s).powers.diagonal).toBe(true) }) }) @@ -40,29 +107,42 @@ describe('Teleport', () => { }) }) -describe('Pierce and Power bombs', () => { - it('Pierce: the flame goes through soft blocks and burns them all', () => { +describe('boosted bombs', () => { + it('each bomb placed uses one of every boost held, until they run out', () => { + let s = board(` + ######### + #P......# + #########`, { capacity: 5 }) + s.boosts.pierce = 1 + s.boosts.nuke = 2 + s = step(s, { id: 'bomb', target: v(2, 1) }).state + expect(s.bombs[0]).toMatchObject({ pierce: true, nuke: true }) + expect(s.boosts).toMatchObject({ pierce: 0, nuke: 1 }) + s.player = v(4, 1) + s = step(s, { id: 'bomb', target: v(5, 1) }).state + expect(s.bombs[1]!.pierce).toBeUndefined() + expect(s.bombs[1]!.nuke).toBe(true) + }) + + it('a thrown bomb uses them too', () => { const s = board(` - ######## - #P.++..# - ########`, { fire: 3 }) - const pierce = { pos: v(2, 1), fire: 3, special: 'pierce' as const } - expect(blastTiles(s, pierce)).toEqual([v(2, 1), v(1, 1), v(3, 1), v(4, 1), v(5, 1)]) - expect(blastTiles(s, { pos: v(2, 1), fire: 3 })).toEqual([v(2, 1), v(1, 1), v(3, 1)]) + ###### + #P...# + ######`) + s.powers.throw = true + s.boosts.power = 1 + const { state } = step(s, { id: 'throw', target: v(3, 1) }) + expect(state.bombs[0]!.power).toBe(true) + expect(state.boosts.power).toBe(0) }) - it('Pierce: placed like a bomb, marked, and burns every block in reach', () => { - let s = board(` + it('Pierce: the flame goes through soft blocks and burns them all', () => { + const s = board(` ######## #P.++..# ########`, { fire: 3 }) - s.charges.pierce = 1 - s = step(s, { id: 'pierce', target: v(2, 1) }).state - expect(s.bombs[0]!.special).toBe('pierce') - s = step(s, { id: 'wait' }).state - s = step(s, { id: 'wait' }).state - const r = step(s, { id: 'wait' }) - expect(r.events.filter((e) => e.type === 'blockDestroyed')).toHaveLength(2) + expect(blastTiles(s, { pos: v(2, 1), fire: 3, pierce: true })).toEqual([v(2, 1), v(1, 1), v(3, 1), v(4, 1), v(5, 1)]) + expect(blastTiles(s, { pos: v(2, 1), fire: 3 })).toEqual([v(2, 1), v(1, 1), v(3, 1)]) }) it('Power: the flame reaches all the way to the walls', () => { @@ -70,75 +150,26 @@ describe('Pierce and Power bombs', () => { ########## #P.......# ##########`) - s.charges.power = 1 - const { state } = step(s, { id: 'power', target: v(2, 1) }) - expect(state.bombs[0]!.special).toBe('power') + s.boosts.power = 1 + const { state } = step(s, { id: 'bomb', target: v(2, 1) }) + expect(state.bombs[0]!.power).toBe(true) expect(blastTiles(state, state.bombs[0]!)).toHaveLength(8) }) -}) - -describe('Line', () => { - it('places all remaining capacity in a row, starting next to the player', () => { - const s = board(` - ######## - #P.....# - ########`, { capacity: 3 }) - s.charges.line = 1 - const { state } = step(s, { id: 'line', target: v(2, 1) }) - expect(state.bombs.map((b) => b.pos)).toEqual([v(2, 1), v(3, 1), v(4, 1)]) - }) - - it('stops where the row is blocked', () => { - const s = board(` - ###### - #P..+# - ######`, { capacity: 5 }) - s.charges.line = 1 - expect(step(s, { id: 'line', target: v(2, 1) }).state.bombs).toHaveLength(2) - }) -}) - -describe('Decoy', () => { - it('is placed next to the player and starts counting down', () => { - const s = board(` - ####### - #P....# - #######`) - s.charges.decoy = 1 - const { state, events } = step(s, { id: 'decoy', target: v(2, 1) }) - expect(events).toContainEqual({ type: 'decoyPlaced', pos: v(2, 1) }) - expect(state.decoy).toEqual({ pos: v(2, 1), turns: DECOY_TURNS - 1 }) - }) - it('chasing enemies head for it instead of the player', () => { + it(`Nuke: every tile within ${NUKE_RANGE} steps burns, through crates, but walls stand`, () => { const s = board(` ####### - #P.S..# + #.....# + #.+#+.# + #..P..# + #.+.+.# + #.....# #######`) - expect(stepToward(s, s.enemies[0]!)?.to).toEqual(v(2, 1)) - s.decoy = { pos: v(5, 1), turns: 2 } - expect(stepToward(s, s.enemies[0]!)?.to).toEqual(v(4, 1)) - }) - - it('disappears after its turns, or when flame reaches it', () => { - let s = board(` - ####### - #P....# - #######`) - s.charges.decoy = 1 - s = step(s, { id: 'decoy', target: v(2, 1) }).state - s = step(s, { id: 'wait' }).state - const last = step(s, { id: 'wait' }) - expect(last.state.decoy).toBeNull() - expect(last.events).toContainEqual({ type: 'decoyGone', pos: v(2, 1), burned: false }) - - const burning = board(` - ###### - #P.1.# - ######`) - burning.decoy = { pos: v(2, 1), turns: 3 } - const r = step(burning, { id: 'wait' }) - expect(r.state.decoy).toBeNull() - expect(r.events).toContainEqual({ type: 'decoyGone', pos: v(2, 1), burned: true }) + const tiles = blastTiles(s, { pos: v(3, 3), fire: 1, nuke: true }) + // 13 tiles within 2 steps, less the pillar at (3, 2); the crates are in. + expect(tiles).toHaveLength(12) + expect(tiles).toContainEqual(v(2, 2)) + expect(tiles).toContainEqual(v(3, 1)) + expect(tiles).not.toContainEqual(v(3, 2)) }) }) diff --git a/test/demo.test.ts b/test/demo.test.ts new file mode 100644 index 0000000..c33190a --- /dev/null +++ b/test/demo.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { generateScene, SCENE_KINDS, type Scene } from '../src/sim/demo' +import { step } from '../src/sim/turn' +import type { GameEvent, GameState } from '../src/sim/types' + +/** Every turn of a scene, replayed by the rules: the state before it and its events. */ +function replay(scene: Scene): { before: GameState; events: GameEvent[]; after: GameState }[] { + let s = scene.start + return scene.actions.map((a) => { + const r = step(s, a) + const turn = { before: s, events: r.events, after: r.state } + s = r.state + return turn + }) +} + +describe('demo scenes', () => { + for (const kind of SCENE_KINDS) { + it(`${kind}: found, replayable, the player alive, and ending on what it shows`, () => { + for (let seed = 1; seed <= 10; seed++) { + const scene = generateScene(kind, seed)! + expect(scene).not.toBeNull() + const turns = replay(scene) + expect(turns.every((t) => t.after.status === 'playing' || t.after.status === 'cleared')).toBe(true) + const last = turns.at(-1)! + const types = last.events.map((e) => e.type) + if (kind === 'kill') expect(last.after.enemies).toEqual([]) + if (kind === 'escape') { + const p = last.before.player + expect(last.events.some((e) => e.type === 'exploded' && e.tiles.some((t) => t.x === p.x && t.y === p.y))).toBe(true) + } + if (kind === 'chain') expect(types.filter((t) => t === 'exploded').length).toBeGreaterThanOrEqual(2) + if (kind === 'freeze') expect(last.before.frozen).toBeGreaterThan(0) + if (kind === 'detonate') expect(types).toContain('detonated') + if (kind !== 'escape' && kind !== 'kill') expect(types).toContain('enemyDied') + } + }) + } + + it('the same seed gives the same scene', () => { + expect(generateScene('kill', 7)).toEqual(generateScene('kill', 7)) + }) +}) diff --git a/test/enemies.test.ts b/test/enemies.test.ts index f0a66dc..a575600 100644 --- a/test/enemies.test.ts +++ b/test/enemies.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { ACTIONS, FREEZE_TURNS, isAvailable } from '../src/sim/actions' -import { PLAN_AHEAD, planIntents, stepToward } from '../src/sim/enemies' +import { LASTING_CHARGES, PLAN_AHEAD, planIntents, rollDrops, stepToward } from '../src/sim/enemies' import { enemyCount, enemyKinds, generateLevel, SAFE_DISTANCE, START } from '../src/sim/levelgen' import { newRun, PAR_BONUS } from '../src/sim/state' import { EXIT_SPAWN, step } from '../src/sim/turn' @@ -173,7 +173,7 @@ describe('plans three moves ahead', () => { ######## #P.....# ########`) - s.enemies.push({ id: 0, kind: 'drifter', pos: v(6, 1), dir: 'left', plan: [v(5, 1), v(4, 1), v(3, 1)], spawned: false }) + s.enemies.push({ id: 0, kind: 'drifter', pos: v(6, 1), dir: 'left', plan: [v(5, 1), v(4, 1), v(3, 1)], spawned: false, sheltered: false }) s.cells[1 * s.width + 4] = 'block' // the second move is now blocked planIntents(s) // Keeps (5,1); the Drifter, blocked going left, bounces back and forth from there. @@ -185,8 +185,8 @@ describe('plans three moves ahead', () => { ###### #P..B# ######`), v(3, 1)) - s.charges.freeze = 1 - const { state } = step(s, { id: 'freeze' }) + s.drops.push({ pos: v(2, 1), kind: 'freeze' }) + const { state } = step(s, { id: 'move', target: v(2, 1) }) expect(state.enemies[0]!.plan[0]).toEqual(v(3, 1)) }) }) @@ -257,13 +257,13 @@ describe('drops', () => { expect(events[1]).toEqual({ type: 'pickedUp', drop: { pos: v(2, 1), kind: 'coins', amount: 1 } }) }) - it('walking onto a charge adds one to that action', () => { + it('walking onto a charge applies it', () => { const s = board(` ##### #P..# #####`) s.drops.push({ pos: v(2, 1), kind: 'shield' }) - expect(step(s, { id: 'move', target: v(2, 1) }).state.charges.shield).toBe(1) + expect(step(s, { id: 'move', target: v(2, 1) }).state.shield).toBe(true) }) 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.shield = 2 + s.boosts.pierce = 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.shield).toBe(2) + expect(state.boosts.pierce).toBe(2) expect(state.enemies.length).toBe(enemyCount(2)) }) @@ -396,21 +396,37 @@ describe('the exit', () => { expect(state.enemies).toHaveLength(EXIT_SPAWN) expect(state.enemies.every((e) => e.spawned && e.kind === 'blob')).toBe(true) }) -}) -describe('action charges and abilities', () => { - it('a charge is only offered while held, and using one spends it', () => { + it('the flame that let them out, still lingering, doesn\'t kill them', () => { 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) + ######## + #P...1E# + ########`) + const spawned = step(s, { id: 'wait' }).state + expect(spawned.burning.length).toBeGreaterThan(0) + const next = step(spawned, { id: 'wait' }).state + expect(next.enemies).toHaveLength(EXIT_SPAWN) + expect(next.enemies.every((e) => !e.sheltered)).toBe(true) }) + it('a new explosion the next turn still kills them', () => { + const s = board(` + ######## + #P...1E# + ########`) + const spawned = step(s, { id: 'wait' }).state + expect(spawned.enemies).toHaveLength(EXIT_SPAWN) + // A fresh bomb next to the exit, going off this turn: its blast covers the exit and beside it. + spawned.bombs.push({ pos: v(5, 1), timer: 1, fire: 1, placedTurn: 0 }) + const { state: next, events } = step(spawned, { id: 'wait' }) + // All three die; flaming the exit again lets out three more. + const first = spawned.enemies.map((e) => e.id) + expect(events.filter((e) => e.type === 'enemyDied').map((e) => 'id' in e && e.id)).toEqual(first) + expect(next.enemies.some((e) => first.includes(e.id))).toBe(false) + }) +}) + +describe('action charges and abilities', () => { it('kick is an ability: only offered once gained, and never used up', () => { const s = board(` ###### @@ -470,8 +486,8 @@ describe('action charges and abilities', () => { ##### #P1.# #####`) - s.charges.shield = 1 - const r = step(s, { id: 'shield' }) + s.shield = true + const r = step(s, { id: 'wait' }) expect(r.state.status).toBe('playing') expect(r.events).toContainEqual({ type: 'shieldBroke', pos: v(1, 1) }) expect(r.state.shield).toBe(false) @@ -491,13 +507,13 @@ describe('action charges and abilities', () => { expect(state.enemies).toEqual([]) // the flame got the Blob on the player's tile }) - it(`freeze: enemies skip this turn's move and the next ${FREEZE_TURNS - 1}, then move again`, () => { + it(`freeze: enemies skip the pickup 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 - s = step(s, { id: 'freeze' }).state + s.drops.push({ pos: v(2, 1), kind: 'freeze' }) + s = step(s, { id: 'move', target: v(2, 1) }).state expect(s.frozen).toBe(FREEZE_TURNS - 1) for (let i = 1; i < FREEZE_TURNS; i++) { s = step(s, { id: 'wait' }).state @@ -508,3 +524,80 @@ describe('action charges and abilities', () => { expect(s.enemies[0]!.pos).not.toEqual(v(6, 1)) }) }) + +describe('enemies spread out', () => { + /** Three enemies of `kind` on one tile in the middle of an open room, none planned yet. */ + function stacked(kind: 'blob' | 'seeker' | 'drifter') { + const s = board(` + ######### + #P......# + #.......# + #.......# + #.......# + #.......# + #########`) + for (let i = 0; i < 3; i++) s.enemies.push({ id: s.nextEnemyId++, kind, pos: v(5, 3), dir: 'up', plan: [], spawned: true, sheltered: false }) + planIntents(s) + return s + } + + for (const kind of ['blob', 'seeker', 'drifter'] as const) { + it(`${kind}s on one tile take different first steps, and never share a tile while they can help it`, () => { + const s = stacked(kind) + for (let t = 0; t < 3; t++) { + const tiles = s.enemies.map((e) => `${e.plan[t]!.x},${e.plan[t]!.y}`) + expect(new Set(tiles).size).toBe(3) + } + }) + } + + it('still share a tile when there is nowhere else to go', () => { + const s = board(` + ##### + #P### + #.B.# + #####`) + s.enemies.push({ id: s.nextEnemyId++, kind: 'blob', pos: v(2, 2), dir: 'left', plan: [], spawned: true, sheltered: false }) + s.cells[2 * s.width + 3] = 'wall' + planIntents(s) + expect(s.enemies.map((e) => e.plan[0])).toEqual([v(1, 2), v(1, 2)]) + }) + + it('their moves are still exactly the ones shown', () => { + let s = stacked('drifter') + const shown = s.enemies.map((e) => e.plan[0]) + s = step(s, { id: 'wait' }).state + expect(s.enemies.map((e) => e.pos)).toEqual(shown) + }) +}) + +describe('the last enemy\'s drop', () => { + it('is a charge that lasts (Pierce, Power, Nuke or Shield), never Freeze or Decoy', () => { + const s = board(` + ##### + #P.D# + #####`) + const kinds = (last: boolean) => { + const seen = new Set<string>() + for (let i = 1; i <= 2000; i++) { + s.dropRng = i * 7919 + for (const d of rollDrops(s, s.enemies[0]!, last)) if (d.kind !== 'coins') seen.add(d.kind) + } + return seen + } + expect([...kinds(true)].sort()).toEqual([...LASTING_CHARGES].sort()) + expect(kinds(false)).toContain('freeze') + expect(kinds(false)).toContain('decoy') + }) + + it('is decided by whether any enemy is left once the turn\'s kills are done', () => { + const s = board(` + ####### + #P.D1D# + #######`) + const { state, events } = step(s, { id: 'wait' }) + expect(state.enemies).toEqual([]) + const charges = events.flatMap((e) => (e.type === 'dropSpawned' && e.drop.kind !== 'coins' ? [e.drop.kind] : [])) + for (const c of charges) expect(LASTING_CHARGES).toContain(c) + }) +}) diff --git a/test/helpers.ts b/test/helpers.ts index d8bbb89..b7c2ec9 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,4 +1,4 @@ -import { noCharges, noPowers, START_LIVES, START_STATS } from '../src/sim/state' +import { noBoosts, noPowers, START_LIVES, START_STATS } from '../src/sim/state' import { DEFAULT_OPTIONS, type Cell, type EnemyKind, type GameState, type Stats, type Vec } from '../src/sim/types' const ENEMIES: Record<string, EnemyKind> = { B: 'blob', S: 'seeker', D: 'drifter' } @@ -20,8 +20,8 @@ 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: 0, decoy: null, burning: [], - powers: noPowers(), star: 0, powerUp: null, runTurns: 0, deaths: 0, crumbling: [], aiRng: 1, dropRng: 1, + boosts: noBoosts(), shield: false, frozen: 0, decoy: null, burning: [], + powers: noPowers(), star: 0, powerUp: null, offer: null, runTurns: 0, deaths: 0, crumbling: [], aiRng: 1, dropRng: 1, } rows.forEach((row, y) => { if (row.length !== width) throw new Error(`row ${y} is ${row.length} wide, not ${width}`) @@ -30,7 +30,7 @@ export function board(picture: string, stats: Partial<Stats> = {}): GameState { if (ch === 'P') player = { x, y } if (/[1-9]/.test(ch)) s.bombs.push({ pos: { x, y }, timer: Number(ch), fire: s.stats.fire, placedTurn: 0 }) const kind = ENEMIES[ch] - if (kind) s.enemies.push({ id: s.nextEnemyId++, kind, pos: { x, y }, dir: 'right', plan: [], spawned: false }) + if (kind) s.enemies.push({ id: s.nextEnemyId++, kind, pos: { x, y }, dir: 'right', plan: [], spawned: false, sheltered: false }) if (ch === 'E' || ch === 'e') s.exit = { x, y } if (ch === '$') s.drops.push({ pos: { x, y }, kind: 'coins', amount: 1 }) }) diff --git a/test/options.test.ts b/test/options.test.ts index 1f942f1..414d656 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { enemyCount, generateLevel, lastLevel, SIZES, START } from '../src/sim/levelgen' import { newRecord, replay } from '../src/sim/record' -import { DEV_STATS, newRun } from '../src/sim/state' +import { newRun } from '../src/sim/state' import { step } from '../src/sim/turn' import { DEFAULT_OPTIONS, type Vec } from '../src/sim/types' import { board, v } from './helpers' @@ -64,23 +64,37 @@ describe('small board', () => { describe('developer mode', () => { const dev = { ...DEFAULT_OPTIONS, dev: true } - it('has every ability, the developer stats and unlimited charges', () => { + it('gives nothing but the gem: the usual stats, abilities and charges', () => { const s = newRun(1, 1, 0, dev) - expect(Object.values(s.powers).every(Boolean)).toBe(true) - expect(s.stats).toEqual(DEV_STATS) - const before = s.charges.freeze - expect(step(s, { id: 'freeze' }).state.charges.freeze).toBe(before) + const plain = newRun(1, 1, 0) + expect(s.stats).toEqual(plain.stats) + expect(s.powers).toEqual(plain.powers) + expect(s.boosts).toEqual(plain.boosts) + expect(s.shield).toBe(plain.shield) }) - it('can still be killed by an enemy, and gets everything back after dying', () => { - const s = { ...board(` - ##### - #PB.# - #####`), options: dev } - const dead = step(s, { id: 'move', target: v(2, 1) }).state + it('the gem is back after a death', () => { + const s = newRun(1, 1, 0, dev) + s.enemies.push({ id: 99, kind: 'blob', pos: { x: 1, y: 2 }, dir: 'up', plan: [], spawned: false, sheltered: false }) + const dead = step(s, { id: 'move', target: { x: 1, y: 2 } }).state expect(dead.status).toBe('dead') const again = step(dead, { id: 'continue' }).state - expect(Object.values(again.powers).every(Boolean)).toBe(true) + expect(again.powerUp?.pos).toEqual({ x: START.x + 1, y: START.y }) + }) + + it('starts every level with the gem uncovered right of the player', () => { + for (const level of [1, 7]) { + const s = newRun(5, level, 0, dev) + expect(s.powerUp?.pos).toEqual({ x: START.x + 1, y: START.y }) + expect(s.cells[START.y * s.width + START.x + 1]).toBe('floor') + const { state } = step(s, { id: 'move', target: s.powerUp!.pos }) + expect(state.offer?.length).toBeGreaterThan(0) + } + }) + + it('a normal run has no gem in the open at the start', () => { + const s = newRun(5) + expect(s.cells[s.powerUp!.pos.y * s.width + s.powerUp!.pos.x]).toBe('block') }) }) diff --git a/test/path.test.ts b/test/path.test.ts index a6a7a01..8a72715 100644 --- a/test/path.test.ts +++ b/test/path.test.ts @@ -32,6 +32,35 @@ describe('auto-move path', () => { expect(playerPath(s, v(1, 1))).toBeNull() }) + it('goes round an open exit unless the exit is the destination', () => { + const s = board(` + ###### + #PE..# + #....# + ######`) + const path = playerPath(s, v(4, 1))! + expect(path).toHaveLength(5) + expect(path).not.toContainEqual(v(2, 1)) + expect(path.at(-1)).toEqual(v(4, 1)) + expect(playerPath(s, v(2, 1))).toEqual([v(2, 1)]) + }) + + it('is null when the only way is over the open exit', () => { + const s = board(` + ###### + #PE..# + ######`) + expect(playerPath(s, v(4, 1))).toBeNull() + }) + + it('crosses a locked exit, which doesn\'t end the level', () => { + const s = board(` + ###### + #PE.B# + ######`) + expect(playerPath(s, v(3, 1))).toEqual([v(2, 1), v(3, 1)]) + }) + it('goes through enemies, which will have moved by then', () => { const s = board(` ###### diff --git a/test/picks.test.ts b/test/picks.test.ts index 28ad3e6..18b65b8 100644 --- a/test/picks.test.ts +++ b/test/picks.test.ts @@ -58,13 +58,13 @@ describe('Move stands for every way of moving', () => { expect(resolvePick(s, 'move', v(5, 1))).toBeNull() }) - it('a diagonal step spends a Diagonal charge only when nothing free reaches the tile', () => { + it('a diagonal step is taken only when nothing else reaches the tile', () => { const s = board(` ##### #P#.# ##..# #####`) - s.charges.diagonal = 1 + s.powers.diagonal = true expect(resolvePick(s, 'move', v(2, 2))).toBe('diagonal') s.powers.teleport = true expect(resolvePick(s, 'move', v(2, 2))).toBe('teleport') @@ -89,8 +89,8 @@ describe('Bomb stands for placing and throwing', () => { ##### #P..# #####`) - s.charges.pierce = 1 - expect(resolvePick(s, 'pierce', v(2, 1))).toBe('pierce') - expect(resolvePick(s, 'pierce', v(3, 1))).toBeNull() + s.powers.teleport = true + expect(resolvePick(s, 'teleport', v(3, 1))).toBe('teleport') + expect(resolvePick(s, 'teleport', v(1, 1))).toBeNull() }) }) diff --git a/test/powerups.test.ts b/test/powerups.test.ts index 34eb212..22d06aa 100644 --- a/test/powerups.test.ts +++ b/test/powerups.test.ts @@ -1,42 +1,140 @@ import { describe, expect, it } from 'vitest' import { ACTIONS, isAvailable } from '../src/sim/actions' -import { generateLevel, LAST_LEVEL, runLevel } from '../src/sim/levelgen' +import { generateLevel, LAST_LEVEL } from '../src/sim/levelgen' import { newRun } from '../src/sim/state' -import { COIN_BAG, EXIT_SPAWN, STAR_TURNS, step } from '../src/sim/turn' +import { COIN_BAG, drawOffer, EXIT_SPAWN, OFFER_SIZE, POWER_UP_WEIGHTS, STAR_TURNS, step } from '../src/sim/turn' import type { GameState, PowerUpKind } from '../src/sim/types' import { board, v } from './helpers' -describe('levelgen: the hidden power-up', () => { +describe('levelgen: the hidden gem', () => { for (let seed = 1; seed <= 100; seed++) { - it(`seed ${seed}: under a soft block that isn't the exit's`, () => { + it(`seed ${seed}: under a soft block that isn't the exit's, in one of the four colours`, () => { const g = generateLevel(seed, 1 + (seed % 50)) expect(g.cells[g.powerUp.pos.y * g.width + g.powerUp.pos.x]).toBe('block') expect(g.powerUp.pos).not.toEqual(g.exit) + expect(['blue', 'green', 'red', 'yellow']).toContain(g.powerUp.color) }) } - - it('Run comes on level 1 or 2, and only there', () => { - for (let seed = 1; seed <= 50; seed++) { - const at = runLevel(seed) - expect([1, 2]).toContain(at) - 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') - } - }) }) -/** A board with the power-up `kind` lying uncovered at (2, 1). */ -function withPowerUp(kind: PowerUpKind, picture = ` +/** A board with the gem lying uncovered at (2, 1). */ +function withGem(picture = ` ###### #P...# ######`): GameState { const s = board(picture) - s.powerUp = { pos: v(2, 1), kind } + s.powerUp = { pos: v(2, 1), color: 'red' } + return s +} + +/** A state waiting for a choice from `offer`. */ +function offering(...offer: PowerUpKind[]): GameState { + const s = board(` + ###### + #P...# + ######`) + s.offer = offer return s } -describe('picking up power-ups', () => { - const take = (kind: PowerUpKind) => step(withPowerUp(kind), { id: 'move', target: v(2, 1) }) +const ORDER = POWER_UP_WEIGHTS.map(([kind]) => kind) + +describe('the offer', () => { + it(`is ${OFFER_SIZE} different power-ups, in the almanac's order, the same for the same seed and level`, () => { + for (let seed = 1; seed <= 50; seed++) { + const s = newRun(seed, 1 + (seed % 20)) + const offer = drawOffer(s) + expect(offer).toHaveLength(OFFER_SIZE) + expect(new Set(offer).size).toBe(OFFER_SIZE) + expect([...offer].sort((a, b) => ORDER.indexOf(a) - ORDER.indexOf(b))).toEqual(offer) + expect(drawOffer(s)).toEqual(offer) + } + }) + + it('leaves out abilities and passes already held', () => { + const s = newRun(3) + s.powers = { run: true, kick: true, throw: true, teleport: true, diagonal: true, detonate: true, wallPass: true, bombPass: false, flamePass: true } + for (let level = 1; level <= 30; level++) { + s.level = level + const offer = drawOffer(s) + expect(offer).toHaveLength(OFFER_SIZE) + for (const k of ['run', 'kick', 'throw', 'teleport', 'diagonal', 'detonator', 'wallPass', 'flamePass'] as PowerUpKind[]) expect(offer).not.toContain(k) + } + }) + + it('is smaller when fewer would do anything', () => { + const s = newRun(3) + s.powers = { run: true, kick: true, throw: true, teleport: true, diagonal: true, detonate: true, wallPass: true, bombPass: true, flamePass: true } + // Left: Bomb+, Fire+, Fuse+, Star, Heart, Coin bag. + expect(drawOffer(s)).toHaveLength(OFFER_SIZE) + }) + + it('offers rare ones less often than common ones', () => { + let fire = 0 + let teleport = 0 + for (let seed = 1; seed <= 400; seed++) { + const offer = drawOffer(newRun(seed)) + if (offer.includes('firePlus')) fire++ + if (offer.includes('teleport')) teleport++ + } + expect(fire).toBeGreaterThan(teleport * 2) + expect(teleport).toBeGreaterThan(0) + }) +}) + +describe('taking the gem', () => { + it('sets up the offer and gives nothing yet; the gem is gone', () => { + const { state, events } = step(withGem(), { id: 'move', target: v(2, 1) }) + expect(state.offer).toEqual(drawOffer(withGem())) + expect(state.powerUp).toBeNull() + expect(state.stats).toEqual(withGem().stats) + expect(events).toContainEqual({ type: 'powerUpTaken', powerUp: { pos: v(2, 1), color: 'red' }, offer: state.offer }) + }) + + it('nothing but choosing is possible until then', () => { + const s = offering('heart', 'bombPlus') + expect(isAvailable(s, 'move')).toBe(false) + expect(isAvailable(s, 'wait')).toBe(false) + expect(() => step(s, { id: 'wait' })).toThrow() + }) + + it('a power-up that is not on offer cannot be chosen', () => { + expect(() => step(offering('heart'), { id: 'choose', kind: 'run' })).toThrow() + }) + + it('choosing takes no turn and nothing else happens', () => { + const s = offering('heart', 'bombPlus') + s.bombs.push({ pos: v(4, 1), timer: 1, fire: 1, placedTurn: 0 }) + const { state, events } = step(s, { id: 'choose', kind: 'heart' }) + expect(events).toEqual([{ type: 'powerUpChosen', kind: 'heart' }]) + expect(state.offer).toBeNull() + expect(state.turn).toBe(s.turn) + expect(state.bombs).toEqual(s.bombs) + }) + + it('a death on the same turn drops the offer; the retry has the gem back', () => { + const s = withGem(` + ###### + #P...# + ######`) + s.bombs.push({ pos: v(3, 1), timer: 1, fire: 1, placedTurn: 0 }) + const { state } = step(s, { id: 'move', target: v(2, 1) }) + expect(state.status).toBe('dead') + expect(state.offer).toBeNull() + }) + + it('reaching the exit on the same turn still needs the choice before the next level', () => { + const s = offering('heart') + s.status = 'cleared' + expect(() => step(s, { id: 'continue' })).toThrow() + const chosen = step(s, { id: 'choose', kind: 'heart' }).state + expect(chosen.lives).toBe(4) + expect(step(chosen, { id: 'continue' }).state.level).toBe(2) + }) +}) + +describe('the power-ups', () => { + const take = (kind: PowerUpKind) => step(offering(kind), { id: 'choose', kind }) it('stats: Bomb+, Fire+, Fuse+', () => { expect(take('bombPlus').state.stats.capacity).toBe(2) @@ -47,43 +145,44 @@ describe('picking up power-ups', () => { it('Heart, Coin bag, Star', () => { expect(take('heart').state.lives).toBe(4) expect(take('coinBag').state.coins).toBe(COIN_BAG) - // Taken this turn, and this turn's end already counts one off. - expect(take('star').state.star).toBe(STAR_TURNS - 1) + // Chosen between turns, so all its turns are still to come. + expect(take('star').state.star).toBe(STAR_TURNS) }) - it('abilities, and the event; the power-up is gone afterwards', () => { - 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: 'run' } }) + it('abilities and passes', () => { + expect(take('run').state.powers.run).toBe(true) 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) + expect(take('diagonal').state.powers.diagonal).toBe(true) + expect(take('wallPass').state.powers.wallPass).toBe(true) + expect(take('bombPass').state.powers.bombPass).toBe(true) + expect(take('flamePass').state.powers.flamePass).toBe(true) }) - it('a power-up still under its block cannot be taken', () => { - const s = withPowerUp('heart', ` + it('a gem still under its block cannot be taken', () => { + const s = withGem(` ###### #P+..# ######`) s.powers.wallPass = true - expect(step(s, { id: 'move', target: v(2, 1) }).state.lives).toBe(3) + expect(step(s, { id: 'move', target: v(2, 1) }).state.offer).toBeNull() }) it('burning its block reveals it', () => { - const s = withPowerUp('heart', ` + const s = withGem(` ###### #P.1+# ######`) s.powerUp!.pos = v(4, 1) const { state, events } = step(s, { id: 'wait' }) - expect(events).toContainEqual({ type: 'powerUpRevealed', powerUp: { pos: v(4, 1), kind: 'heart' } }) + expect(events).toContainEqual({ type: 'powerUpRevealed', powerUp: { pos: v(4, 1), color: 'red' } }) expect(state.powerUp).not.toBeNull() }) it('flame on it once uncovered destroys it and lets enemies out', () => { - const s = withPowerUp('heart', ` + const s = withGem(` ###### #P..1# ######`) @@ -201,16 +300,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 Run, Kick, Throw, Teleport and stats', () => { + it('a death keeps every power-up and the stats (since 2026-10-23)', () => { const s = { ...board(` ##### #P1.# #####`, { capacity: 2 }), star: 0 } - s.powers = { run: true, kick: true, throw: true, teleport: true, detonate: true, wallPass: true, bombPass: true, flamePass: false } + s.powers = { run: true, kick: true, throw: true, teleport: true, diagonal: 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, kick: true, throw: true, teleport: true, detonate: false, wallPass: false, bombPass: false, flamePass: false }) + expect(again.powers).toEqual(s.powers) expect(again.stats.capacity).toBe(2) }) diff --git a/test/sprites.test.ts b/test/sprites.test.ts new file mode 100644 index 0000000..f7e6aee --- /dev/null +++ b/test/sprites.test.ts @@ -0,0 +1,23 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** A PNG's width and height, from its IHDR chunk. */ +function pngSize(file: string): string { + const png = readFileSync(file) + return `${png.readUInt32BE(16)} × ${png.readUInt32BE(20)}` +} + +describe('board sprites', () => { + // LittleJS cuts a 128 × 128 tile out of each board texture (render/sprites.ts). A 100 px file + // gave an invalid tile, which crashed the game when a Freeze drop was drawn (2026-10-23). + for (const dir of ['assets/kenney', 'assets/composed']) { + it(`every PNG in ${dir} is 128 × 128`, () => { + const wrong = readdirSync(dir) + .filter((f) => f.endsWith('.png')) + .map((f) => `${f}: ${pngSize(join(dir, f))}`) + .filter((line) => !line.endsWith('128 × 128')) + expect(wrong).toEqual([]) + }) + } +}) diff --git a/tools/compose-icons.sh b/tools/compose-icons.sh index 2ba826b..54fd6d9 100644 --- a/tools/compose-icons.sh +++ b/tools/compose-icons.sh @@ -15,8 +15,10 @@ 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). +# Always 128 × 128, like every board sprite: LittleJS cuts tiles of 128 out of each texture, and a +# 100 px Game Icons file (the padlock) made its tile invalid, which crashed the game (2026-10-23). ink() { - magick "$1" -fill '#353541' -colorize 100 "$OUT/$2.png" + magick "$1" -resize 128x128 -fill '#353541' -colorize 100 "$OUT/$2.png" echo " $OUT/$2.png" } @@ -43,7 +45,32 @@ 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 +# Diagonal: the Move figure with four diagonal arrows (a charge until 2026-10-23). +badge "$OUT/icon_move.png" "$B/arrow_diagonal_cross.png" powerup_diagonal 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 + +# The charges (user request, 2026-10-23: they work on pickup now). Pierce and Power reuse the +# Wall Pass and Fire+ icons above; the rest: +badge "$K/bomb.png" "$B/skull.png" charge_nuke +ink "$I/locked.png" charge_freeze +ink "$B/pawns.png" charge_decoy +# Shield: the Move figure inside an empty ring (the board-game timer at 0). +magick "$B/timer_0.png" -fill '#353541' -colorize 100 \ + \( "$OUT/icon_move.png" -resize 84x84 \) -gravity center -composite \ + "$OUT/charge_shield.png" +echo " $OUT/charge_shield.png" + +# The app's icons for installing it (user request, 2026-10-23): the bomb on the sky blue, in +# static/icons/. "Maskable" keeps the bomb inside the middle 60%, so any mask shape fits round it. +mkdir -p static/icons +appicon() { + magick -size "$1x$1" xc:'#c3e3ff' \( "$K/bomb.png" -resize "$2x$2" \) -gravity center -composite "static/icons/$3.png" + echo " static/icons/$3.png" +} +appicon 192 150 icon-192 +appicon 512 400 icon-512 +appicon 512 300 icon-maskable-512 +appicon 180 140 apple-touch-icon diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index b056494..e138a8c 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -51,7 +51,7 @@ const SHOTS = [ { name: 'desktop', viewport: DESKTOP, act: press('KeyB', 'KeyD', 'KeyS') }, // Level 13 has all three enemy types so far, each with its intent arrow. { name: 'enemies', query: 'level=13', act: press('KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD') }, - // Tapped a far tile: the flag, the walk there, and Move turned into Go; then one Go. + // Tapped a far tile: the way there in blue, and Move turned into Go; then one Go. { name: 'auto-move', act: async (page) => { await tap(3, 3)(page); await page.click('[data-action=move]') } }, // Held a finger on the tile to the right: a bomb is armed there, with its blast, until release. { @@ -63,21 +63,18 @@ const SHOTS = [ await page.waitForTimeout(600) }, }, - // 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 Run under the crate two tiles below the start: bomb it open + // With every lasting charge (?charges=1): a bomb placed to the right wears the Pierce, Power and + // Nuke badges, and after a step down the danger shows the Nuke's reach; the top bar lists them. + { name: 'nuke', query: 'charges=1', act: press('KeyB', 'KeyD', 'KeyS') }, + // Seed 55 hides the gem 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. - { - name: 'run', - query: 'seed=55', - act: press('KeyB', 'KeyS', 'KeyD', 'KeyD', 'Space', 'KeyA', 'KeyA', 'KeyS', 'KeyS', 'KeyR'), - }, + { name: 'gem', query: 'seed=55', act: press('KeyB', 'KeyS', 'KeyD', 'KeyD', 'Space', 'KeyA') }, + // ...then walk back and take it: the choice of power-ups. + { name: 'gem-offer', query: 'seed=55', act: press('KeyB', 'KeyS', 'KeyD', 'KeyD', 'Space', 'KeyA', 'KeyA', 'KeyS', 'KeyS') }, // A small board fits the screen whole: no scrolling. { name: 'small', query: 'small', act: async () => {} }, { name: 'small-desktop', viewport: DESKTOP, query: 'small', act: async () => {} }, - // Developer mode: every ability and unlimited charges (the ∞ badges); its tag in the top bar. + // Developer mode: the level's gem uncovered right of the player, and the mode's tag in the top bar. { name: 'dev', query: 'dev', act: async () => {} }, // "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. @@ -104,10 +101,12 @@ const SHOTS = [ 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]') } }, + // What picked-up charges still give, in the top bar's own row (?charges=2: 6 bombs of each, a Shield). + { name: 'charges', query: 'charges=2', act: async () => {} }, + // The demo before the game (?demo): a scene made up on the spot, part-way through, with its caption. + { name: 'demo', query: 'demo', act: async (page) => { await page.waitForTimeout(3500) } }, + // Move picked: the tiles it can step to. + { name: 'move-picked', act: async (page) => { await page.click('[data-action=move]') } }, ] mkdirSync(OUT, { recursive: true }) diff --git a/vite.config.ts b/vite.config.ts index 63193ba..a22b448 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,9 +10,13 @@ const base = process.env.BASE || './' export default defineConfig({ base, - // `npm run dev` binds to every interface (--host) so a phone on the same Wi-Fi can open the - // game; this prints a scannable QR of that network URL alongside it. + // `npm run dev` and `npm run preview` bind to every interface (--host) so a phone on the same + // Wi-Fi can open the game; this prints a scannable QR of that network URL alongside it. plugins: [qrcode()], + // In `npm run dev`, the game's console output and uncaught errors, from any device (a phone on + // the Wi-Fi too), are printed in this terminal (user request, 2026-10-23: a crash on the phone + // showed nothing anywhere). Caught errors are logged with their stack by src/devlog.ts. + server: { forwardConsole: { unhandledErrors: true, logLevels: ['error', 'warn', 'info', 'log'] } }, // Not `public/`: GitLab Pages requires its artifact to be a directory named `public`, and // `mv dist public` moves dist *inside* an existing `public/` instead of renaming it. publicDir: 'static',