explodorama / commit
RPG, Proximity alert, Slide and the ICBM charge; Run extends Teleport
CHANGELOG.md | 1 + PLAN.md | 8 +- README.md | 26 ++-- assets/composed/charge_icbm.png | Bin 0 -> 6725 bytes assets/composed/powerup_proximity.png | Bin 0 -> 6151 bytes assets/composed/powerup_rpg.png | Bin 0 -> 3146 bytes assets/composed/powerup_slide.png | Bin 0 -> 2254 bytes assets/kenney-board-icons/arrow_horizontal.png | Bin 0 -> 616 bytes assets/kenney-icons/White/forward.png | Bin 0 -> 15117 bytes assets/kenney-icons/White/target.png | Bin 0 -> 15427 bytes assets/kenney-icons/White/warning.png | Bin 0 -> 15315 bytes decisions.md | 70 ++++++++++ docs/screenshots/almanac-charges.png | Bin 282941 -> 314118 bytes docs/screenshots/almanac.png | Bin 410063 -> 523978 bytes docs/screenshots/auto-move.png | Bin 217157 -> 217119 bytes docs/screenshots/bomb-picked.png | Bin 216471 -> 216434 bytes docs/screenshots/charges.png | Bin 228162 -> 228670 bytes docs/screenshots/countdown.png | Bin 221116 -> 221112 bytes docs/screenshots/death.png | Bin 235128 -> 235106 bytes docs/screenshots/demo.png | Bin 973013 -> 963970 bytes docs/screenshots/dev-offer.png | Bin 200791 -> 203343 bytes docs/screenshots/enemies.png | Bin 220425 -> 220100 bytes docs/screenshots/gem-offer.png | Bin 214178 -> 202477 bytes docs/screenshots/gem.png | Bin 214188 -> 200500 bytes docs/screenshots/info-hud.png | Bin 175377 -> 179052 bytes docs/screenshots/lingering.png | Bin 219627 -> 219582 bytes docs/screenshots/long-press.png | Bin 219891 -> 219831 bytes docs/screenshots/menu.png | Bin 142122 -> 141390 bytes docs/screenshots/move-picked.png | Bin 216615 -> 216563 bytes docs/screenshots/nuke.png | Bin 240183 -> 241416 bytes src/main.ts | 11 +- src/render/board.ts | 11 +- src/render/effects.ts | 32 ++++- src/render/sprites.ts | 16 ++- src/sim/actions.ts | 117 ++++++++++++++-- src/sim/danger.ts | 7 +- src/sim/demo.ts | 39 +++++- src/sim/enemies.ts | 2 +- src/sim/explosions.ts | 14 ++ src/sim/picks.ts | 14 +- src/sim/record.ts | 2 +- src/sim/state.ts | 9 +- src/sim/turn.ts | 21 ++- src/sim/types.ts | 22 +++- src/ui/almanac.ts | 2 +- src/ui/clip.ts | 2 +- src/ui/controls.ts | 7 +- src/ui/info.ts | 28 +++- test/animation.test.ts | 21 ++- test/demo.test.ts | 2 +- test/enemies.test.ts | 5 +- test/new-powers.test.ts | 176 +++++++++++++++++++++++++ test/powerups.test.ts | 6 +- tools/compose-icons.sh | 8 ++ tools/screenshots.mjs | 11 +- 55 files changed, 611 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd23d57..281e921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ saved before the change is dropped and the next visit starts a new run. ### Added +- **New power-ups: RPG** (fire a bomb as far as it flies), **Proximity alert** (your bombs go off when an enemy is in reach) and **Slide** (slide until a wall, crate or bomb), and a new charge, **ICBM** (3 launches of a bomb that goes off where it lands). With Run, Teleport reaches 4 steps. Version 24: runs saved before don't resume. - **The "Ouch!" banner replays your own last turns** before a death. The gem's offer plays a clip of the power-up you look at, and almanac entries open their clip. - **Clips in the info dialogs:** most of them play a short scene of what they describe, new every loop, with a finger showing how for the actions and the power-ups you work yourself. - **Report a bug** in the menu: an email to the project's GitLab Service Desk (no account needed), filled in with the game's version, the device and the run, so it can be replayed. diff --git a/PLAN.md b/PLAN.md index 3950f96..3fcaf81 100644 --- a/PLAN.md +++ b/PLAN.md @@ -162,8 +162,11 @@ they first appear on). | **Run** | Run pickup (called Skates until 2026-10-22; NES "Speed"), or shop | 2 tiles in a straight line. **Both** tiles must be free — no jumping over anything. Highlight: path tile + landing tile. An enemy on the path tile = death (you ran into it). *Part of Move since 2026-10-22.* | | **Kick** | Kick power-up (*was a charge until 2026-10-22*) | Push an adjacent bomb by walking into it; it slides until the next tile is not free, keeping its timer. Part of Move. | | **Throw** | Throw power-up (*was a charge*) | A bomb **exactly 2 tiles away** in a straight line, over a crate, bomb or enemy; *since 2026-10-23 (user's choice)* not over a pillar or the outer wall (it went 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. *Since 2026-10-23 (user request)* auto-move uses it: each move is a step, a Run or a Teleport, whichever leaves the walk left shortest (ties: Teleport, then Run), a neighbour always a step (`autoMoveAction` in `sim/path.ts`). | +| **Teleport** | Teleport power-up, rare (*was a charge*) | Move to any free tile within Manhattan distance 3 (*4 with Run too, since 2026-10-24, user request: Teleport reaches every tile a run does, so Run extends it*), over anything. Part of Move. *Since 2026-10-23 (user request)* auto-move uses it: each move is a step, a Run or a Teleport, whichever leaves the walk left shortest (ties: Teleport, then Run), a neighbour always a step (`autoMoveAction` in `sim/path.ts`). | | **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). | +| **Slide** | Slide power-up (*2026-10-24, user request*; weight 6) | Slide in a straight line until the next tile is a wall, crate or bomb (user's choice); can't stop early. Picks up what it passes; an enemy on the way is crashed into (death, as Run's); flame on the way burns unless flame-safe; the open exit stops it. Highlight: the end tile, 2+ away. Part of Move, after Teleport (a teleport to the same tile wins). L. A tap on the end tile with nothing picked slides too (`tapMeaning`, before auto-move's walk; user report, same day). | +| **RPG** | RPG power-up (*2026-10-24, user request*; weight 6) | Fire a bomb in a straight line: it flies until the next tile is a wall, crate, bomb or enemy (or flame) and lands just before it (user's choice), 2+ tiles away (1 is a plain bomb); counts down as usual, within the bomb limit. Part of Bomb. F. | +| **Proximity alert** | Proximity power-up (*2026-10-24, user request*; weight 5) | Passive: the player's bombs placed while held go off the turn an enemy is in their reach, after the enemies move — one that walked in or one already there (user's choice). The danger overlay predicts it from the enemies' next moves. | | **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. | *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.) @@ -186,6 +189,7 @@ dialog saying what it does (`chargeInfo`). Icons are the user's choices (§13). | **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. | +| **ICBM** | *2026-10-24, user request.* 3 launches (`boosts.icbm`, user's choice): Bomb fires a bomb as an RPG's, at any distance, and it goes off that same turn (timer 0); the bomb limit doesn't apply; it works without the RPG power-up. While launches are left, a far tile picked with Bomb is an ICBM (before RPG in the group). Lasts to the next level. I. Icon: bomb + target badge. | | ~~**Line**~~ | *Removed 2026-10-23* (not in the user's new list). | | ~~**Fuse +1 / Fuse −1**~~ | *Removed 2026-10-22 (user request).* | @@ -718,7 +722,7 @@ crate, an enemy, a tile an enemy is going to, or another bomb. *Since 2026-10-24 or in one room in six already uncovered (locked while enemies live). *Since 2026-10-24 (user request)* the menu's **Watch the demo** plays it again; a tap returns to the run as it was. *Since 2026-10-24 (user request)* the scenes play **in one fixed order**, looped (`DEMO_ORDER` in main.ts: the controls' seven, then the table's living scenes with -a way to die after every four); the panel numbers them (n / 43), ◀ ▶ and ← → step through them, +a way to die after every four); the panel numbers them (n / 47 since the new power-ups; 43 at first), ◀ ▶ and ← → step through them, ↺ goes back to the first, and the place is kept in `localStorage` so the demo carries on there next time, after a reload too (not on a test URL, which starts at the first). Scenes are made in a Web Worker (`ui/scenes.ts`), the next while one plays. Ideas: a gem's choice, and showing the diff --git a/README.md b/README.md index 97942a4..0fdc256 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ seven charges that work when picked up, a hidden gem on every level that offers mid-run work. The shop, the Keep / Fresh choice on death, curses and more enemies are planned — see [PLAN.md](PLAN.md). -Version: **23**, shown at the bottom of the menu with the commit the game was built from (a link +Version: **24**, shown at the bottom of the menu with the commit the game was built from (a link to it on GitLab, plus "plus local changes" in a build with uncommitted changes) and a link to the [source code](https://gitlab.com/PavelSafronov/explodorama). The version is the rules version (`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)): it goes up by one whenever a rule @@ -45,15 +45,16 @@ screen — and the finger in front of the glass. The rest show every feature on board: bombs and their countdown, watching the enemies' arrows to bomb where they're going, a narrow escape, chain reactions, lingering flame, the three enemy types, drops, the gem, every power-up you can see in action (Bomb +1, Fire +1, Fuse +1, Run, Kick — a tap on a bomb next to -you —, Throw, Teleport, Diagonal, the Detonator — a tap on your bomb —, Wall / Bomb / Flame Pass, +you —, Throw, Teleport, Diagonal, Slide, RPG, the Detonator — a tap on your bomb —, Proximity +alert, Wall / Bomb / Flame Pass, Star) and every charge (Pierce, Power, Nuke, Shield, -Freeze, Decoy), and the exit (walking out once it opens; blasting it lets enemies out). Heart and +Freeze, Decoy, ICBM), and the exit (walking out once it opens; blasting it lets enemies out). Heart and Coin bag only change numbers, so they have no scene; nor does the shop. Every other scene shows **a way to die**: walking into a blast, not getting far enough, blocking yourself in with your own bomb, one bomb setting off another, touching an enemy, and a couple of funny ones (the Detonator next to you, a Run into an enemy). The scenes play **in a fixed order** and loop — the controls first, then the rest, with a way to die after every four — each made new every -time. The panel shows which one it is (**12 / 43**); **◀ ▶** (or the **← →** keys) go to the +time. The panel shows which one it is (**12 / 47**); **◀ ▶** (or the **← →** keys) go to the one before or after, and **↺** back to the first. The demo carries on where it was the next time it plays, after a reload too. It is view-only; **tap anywhere else (or press any other key) to play**, and that tap does nothing @@ -81,13 +82,14 @@ 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 | **M**, then a direction — or W A S D / arrow keys straight away (Q E Z C diagonally, with Diagonal) | 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 a crate, bomb or enemy (not a pillar). Pierce, Power and Nuke charges change your next bombs by themselves (see Charges). | +| **Move** | Tap a tile next to you, or tap **Move** and then any highlighted tile | **M**, then a direction — or W A S D / arrow keys straight away (Q E Z C diagonally, with Diagonal) | 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 (4 with Run too), over anything. **With Kick:** walking into a bomb kicks it. **With Diagonal:** or a diagonal step. **With Slide:** or slide in a straight line until a wall, crate or bomb stops you. | +| **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 a crate, bomb or enemy (not a pillar). **With RPG:** or fired in a straight line, as far as it flies. **With ICBM launches:** a far tile fires an ICBM, which goes off at once. 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. With Diagonal, a diagonal neighbour is a step too — tap it, picked or not — and Teleport is +kick, into a bomb), 2 straight is a run, further is a teleport (or, out of its reach, where a slide +stops). Without picking anything, a tap on the tile where a slide stops slides there; next to you is a bomb, 2 away a throw, further an ICBM while you have launches, otherwise an +RPG bomb. With Diagonal, a diagonal neighbour is a step too — tap it, picked or not — and Teleport is for tiles further away. **Auto-move:** tap any tile further away that you can walk to — empty floor, the exit, the @@ -159,6 +161,7 @@ through a death. The first time in a run you pick up each kind, a dialog says wh | **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. | +| **ICBM** | bomb with a target badge | 3 launches: **Bomb** fires a bomb as an RPG's (or **I**, then a direction) and it goes off the moment it lands, at any distance. Your bomb limit doesn't count. While you have launches, a far tile picked with Bomb is an ICBM. | 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. @@ -269,9 +272,12 @@ before moving on. | **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 a crate, bomb or enemy in between — but not over a pillar or the outer wall (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). | +| **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). **With Run as well, 4 steps:** Teleport reaches everywhere a run does, so Run adds to it instead. | | **Diagonal** | the Move figure with a diagonal-arrows badge | **Move** can also step diagonally, even squeezing between two blocked tiles (or **Q E Z C**: up-left, up-right, down-left, down-right). A charge until 2026-10-23. | +| **Slide** | the Move figure with a two-way-arrow badge | **Move** can also slide in a straight line until the next tile is a wall, crate or bomb — **tap the tile where it stops** (or **L**, then a direction) — picking up whatever it passes. It can't stop early: an enemy on the way is crashed into (a life lost), and flame on the way burns (unless Flame Pass or a Star). The open exit stops it. | +| **RPG** | bomb with a forward-arrow badge | **Bomb** can also fire a bomb in a straight line (or **F**, then a direction): it flies until the next tile is a wall, crate, bomb or enemy and lands just before it, then counts down as usual. | | **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. | +| **Proximity alert** | bomb with a warning-sign badge | Your bombs go off the turn an enemy is in their reach — one that walks in, or one already there when you place it — wherever you are standing. The danger overlay shows it ahead, from the enemies' arrows. | | **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. | @@ -279,7 +285,7 @@ before moving on. | **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 +Run, Kick, Throw, Teleport, Diagonal, Slide and RPG 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. diff --git a/assets/composed/charge_icbm.png b/assets/composed/charge_icbm.png new file mode 100644 index 0000000..bf2a996 Binary files /dev/null and b/assets/composed/charge_icbm.png differ diff --git a/assets/composed/powerup_proximity.png b/assets/composed/powerup_proximity.png new file mode 100644 index 0000000..fd7ea53 Binary files /dev/null and b/assets/composed/powerup_proximity.png differ diff --git a/assets/composed/powerup_rpg.png b/assets/composed/powerup_rpg.png new file mode 100644 index 0000000..a33165a Binary files /dev/null and b/assets/composed/powerup_rpg.png differ diff --git a/assets/composed/powerup_slide.png b/assets/composed/powerup_slide.png new file mode 100644 index 0000000..4b55b6d Binary files /dev/null and b/assets/composed/powerup_slide.png differ diff --git a/assets/kenney-board-icons/arrow_horizontal.png b/assets/kenney-board-icons/arrow_horizontal.png new file mode 100644 index 0000000..b2279e5 Binary files /dev/null and b/assets/kenney-board-icons/arrow_horizontal.png differ diff --git a/assets/kenney-icons/White/forward.png b/assets/kenney-icons/White/forward.png new file mode 100644 index 0000000..edf9f6f Binary files /dev/null and b/assets/kenney-icons/White/forward.png differ diff --git a/assets/kenney-icons/White/target.png b/assets/kenney-icons/White/target.png new file mode 100644 index 0000000..a7961da Binary files /dev/null and b/assets/kenney-icons/White/target.png differ diff --git a/assets/kenney-icons/White/warning.png b/assets/kenney-icons/White/warning.png new file mode 100644 index 0000000..12ebcb1 Binary files /dev/null and b/assets/kenney-icons/White/warning.png differ diff --git a/decisions.md b/decisions.md index 20843d5..d880bdb 100644 --- a/decisions.md +++ b/decisions.md @@ -1528,3 +1528,73 @@ hidden clip canvas didn't hide (`canvas.clip` sets `display: block`, which beats `canvas.clip[hidden]` is `display: none` now — the "Ouch!" banner's canvas, hidden when there is no replay, relied on it too. Checked in a browser: Heart and Coin bag show their pictures, Kick its clip again. + +### 2026-10-24 — The gem screenshots show the gem again + +User report: `gem-offer.png` showed the board, not the offer. Its seed-55 key presses no longer +reached the gem (the levels have changed since they were written), and `gem.png`, the step +before, showed no gem either. A short search with the sim — random moves, bombs and waits on +seeds 1–120, keeping the shortest that ends with the gem taken and the player alive — found seed +107: down, a bomb below, wait, up, wait three times, down twice (the gem uncovered beside the +player: `gem.png`), right onto it (`gem-offer.png`, the offer open). + +### 2026-10-24 — Run extends Teleport; RPG, Proximity alert, Slide and the ICBM charge + +User requests: with Teleport, Run should extend it (Teleport already reaches every tile a run +does); a power-up to "send the bomb as far as it can fly" (RPG); a charge that flings the bomb +the same way but explodes instantly (ICBM); a power-up that explodes a bomb when an enemy steps +into its range (Proximity alert); and a power-up to slide in a straight line until a wall or a +crate, its highlight showing the next tile and then the far tile where the slide ends. The rules +below are the user's choices of the options offered (all the recommended ones). + +- **Run + Teleport:** Teleport reaches 4 steps instead of 3 while Run is held too + (`teleportRange`). Run's own move stays, and is never better than a teleport. +- **RPG** (power-up, weight 6, part of Bomb, key F): fired in a straight line, the bomb flies until + the next tile is a wall, crate, bomb or enemy and lands just before it (`flightEnd`), 2 tiles away + or more (1 is a plain bomb); it counts down as usual, within the bomb limit. It flies straight, + like a kicked bomb (`bombFired`), not in a throw's arc. +- **ICBM** (charge, key I): 3 launches per pickup, added up like Pierce; Bomb fires one as an RPG + bomb at any distance and it goes off that same turn; the bomb limit doesn't count, and it works + without the RPG power-up. It lasts past the last enemy, so the last one may drop it. Choice made + here: while launches are left, a far tile picked with Bomb is an ICBM, not an RPG bomb (Bomb's + group puts ICBM first; the preview shows it going off) — otherwise a player holding both could + only use launches by key. The other way round would hide the ICBM behind every RPG target. +- **Proximity alert** (power-up, weight 5, passive): bombs placed while it is held carry the flag; + after the enemies move, each one with an enemy in its reach is set to go off that turn + (`proximityAlert`), so an enemy already in reach when the bomb is placed sets it off too — + with the player perhaps beside it. The danger overlay runs the same check on where the enemies' + arrows say they will be, this turn and the next. +- **Slide** (power-up, weight 6, part of Move, key L): over free floor until the next tile is a + wall, crate or bomb (`slidePath`); it picks up what it passes (coins, charges, an uncovered gem), + crashes into an enemy on the way (a life lost, like Run's) and burns in flame on the way unless + flame can't hurt; the open exit stops it there. Highlighted is the end tile, 2 or more away + (next to the player is a step). A teleport to the same tile wins (it can't crash or burn). +- **Keys:** with an action picked, a direction now takes the nearest tile that way it can use, at + any distance (it tried 1 and 2), so F, I and L then a direction work. +- **Icons** (`tools/compose-icons.sh`, from Game Icons and Board Game Icons, packs already in use): + RPG a bomb with a forward arrow, Proximity a bomb with a warning sign, Slide the Move figure with a + two-way arrow, ICBM a bomb with a target. +- **The demo and clips:** four new scenes (rpg, proximity, slide, icbm; 47 in all), the finger in + the RPG, ICBM and Slide clips; the almanac, the offer, the top bar and the texts know all four. +- Offers and drops draw from more kinds now, so old runs would replay differently: `SIM_VERSION` + 24. Tests: the new rules each (13), and every demo scene still found on every seed tried (40 per + kind, the slowest 0.38 s). Checked in a browser: the four new demo scenes play. + +### 2026-10-24 — A slide glides + +User report: a slide looked jumpy, like half a dozen evenly spaced steps. Every move took a step's +0.16 s whatever its length, so six tiles went by in about ten frames, each a big jump. A move of +more than 2 tiles that isn't a teleport (a slide) now takes 0.07 s per tile — 0.42 s for six — +eases out (fast at first, slowing as it stops), and is drawn crouched, with no hop and no walking +frames (`isSlide`, `moveProgress` in effects.ts; the "You" tag and the Shield's ring follow the +same curve). Steps and runs are as they were. Checked on a video of the demo's slide scene: the +player moves a little less each frame down to the stop, crouched. + +### 2026-10-24 — A tap on a slide's end slides + +User report: tapping a tile a slide reaches, with nothing picked, walked there by auto-move +instead of sliding. A tap's meaning (`tapMeaning`) now has a slide after a step and a diagonal +step and before auto-move's walk: the tile where a slide stops, with Slide held, is a slide. A +tile that is also a teleport target still slides only if picked with Move (there a teleport wins, +being safe); with nothing picked, a tile you can walk to was never a teleport anyway. The demo's +slide scene now shows the plain tap (its caption: "tap where you stop"). diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png index e3f0657..5ff7e35 100644 Binary files a/docs/screenshots/almanac-charges.png and b/docs/screenshots/almanac-charges.png differ diff --git a/docs/screenshots/almanac.png b/docs/screenshots/almanac.png index 810ccae..15aef7f 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 a033ca7..7f29757 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 0a79c17..a9fefb5 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 1d89a7f..b9d20a6 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 cc3c063..0ac284f 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 3e07817..799938e 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 index edfdb53..39dc8eb 100644 Binary files a/docs/screenshots/demo.png and b/docs/screenshots/demo.png differ diff --git a/docs/screenshots/dev-offer.png b/docs/screenshots/dev-offer.png index 39ca629..b35771f 100644 Binary files a/docs/screenshots/dev-offer.png and b/docs/screenshots/dev-offer.png differ diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png index ab0b126..6e4d19b 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 index 9f05d0b..55212c0 100644 Binary files a/docs/screenshots/gem-offer.png and b/docs/screenshots/gem-offer.png differ diff --git a/docs/screenshots/gem.png b/docs/screenshots/gem.png index 37727c7..508a005 100644 Binary files a/docs/screenshots/gem.png and b/docs/screenshots/gem.png differ diff --git a/docs/screenshots/info-hud.png b/docs/screenshots/info-hud.png index f46d8f6..4706a01 100644 Binary files a/docs/screenshots/info-hud.png and b/docs/screenshots/info-hud.png differ diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png index 38164a7..77624f6 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 c927e1f..67fc5c6 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 c2b7886..731636a 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 f8790a3..9591e51 100644 Binary files a/docs/screenshots/move-picked.png and b/docs/screenshots/move-picked.png differ diff --git a/docs/screenshots/nuke.png b/docs/screenshots/nuke.png index c4880b2..2a1bac9 100644 Binary files a/docs/screenshots/nuke.png and b/docs/screenshots/nuke.png differ diff --git a/src/main.ts b/src/main.ts index 57e1417..db872c6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -528,10 +528,12 @@ function pick(id: ActionId, pressed = selected === id): void { */ function direction(d: Dir): void { if (state.status !== 'playing') return - // Nothing picked: a step, or a kick into a bomb. A picked button reaches 2 tiles too (a run, - // a teleport, a throw) where 1 tile can't be used. + // Nothing picked: a step, or a kick into a bomb. A picked button reaches further too (a run, + // a teleport, a throw; an RPG, an ICBM, a slide as far as they go): the nearest tile that way + // it can use. const picked: ActionId = selected && ACTIONS[selected].needsTarget ? selected : 'move' - for (const reach of selected ? [1, 2] : [1]) { + const reaches = selected ? Array.from({ length: Math.max(state.width, state.height) }, (_, i) => i + 1) : [1] + for (const reach of reaches) { const p = { x: state.player.x + reach * DIRS[d].x, y: state.player.y + reach * DIRS[d].y } const id = resolvePick(state, picked, p) if (id) return act({ id, target: p }) @@ -562,6 +564,8 @@ const ACTION_KEYS: Record<string, ActionId> = { // M picks Move, as B picks Bomb (user request, 2026-10-23; the button said "WASD"). KeyM: 'move', KeyB: 'bomb', KeyR: 'run', KeyX: 'detonate', KeyK: 'kick', KeyT: 'throw', KeyG: 'diagonal', KeyP: 'teleport', + // Added 2026-10-24: F fires an RPG bomb, I an ICBM, L slides. + KeyF: 'rpg', KeyI: 'icbm', KeyL: 'slide', } /** Q / E / Z / C: a diagonal step up-left, up-right, down-left, down-right (with Diagonal). */ @@ -789,6 +793,7 @@ const SCENE_ICONS: Record<SceneKind, string> = { rainbowGem: 'gem_green', immortal: 'star', dieIntoBlast: 'fireball', dieNotFar: 'fireball', dieBlockedIn: 'bomb', dieChain: 'bomb', dieEnemy: 'slime_normal_rest', dieDetonator: POWER_UP_ICONS.detonator, dieRun: POWER_UP_ICONS.run, + rpg: POWER_UP_ICONS.rpg, proximity: POWER_UP_ICONS.proximity, slide: POWER_UP_ICONS.slide, icbm: CHARGE_ICONS.icbm, } diff --git a/src/render/board.ts b/src/render/board.ts index 22a74e9..a423fd3 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, moveSeconds, progress, pulseScale, TELEPORT_SECONDS, WALK_SECONDS, + appearScale, dropSprite, frostCrack, frostLevel, GONE_SECONDS, isSlide, key, moveProgress, moveSeconds, progress, pulseScale, TELEPORT_SECONDS, WALK_SECONDS, type Effects, type Gone, type PlayerMove, type PlayerPose, } from './effects' import { CHARGE_ICONS, digitSprite, ENEMY_SPRITES, GEM_SPRITES, sprite, wallSprite, type BoardSprite } from './sprites' @@ -247,7 +247,7 @@ export function playerPos(s: GameState, move: PlayerMove | null): Vector2 { const t = move ? time - move.at : Infinity if (!move || t >= moveSeconds(move)) return to if (move.teleport) return t < TELEPORT_SECONDS ? toWorld(s, move.from) : to - return toWorld(s, move.from).lerp(to, t / WALK_SECONDS) + return toWorld(s, move.from).lerp(to, moveProgress(move, t)) } /** @@ -273,7 +273,12 @@ function drawPlayer(s: GameState, move: PlayerMove | null, pose: PlayerPose, fac drawTile(out ? toWorld(s, move.from).add(PLAYER_LIFT) : to, vec2(k), sprite('character_green_front'), C.white, (1 - k) * 2, facingLeft) return } - const k = t / WALK_SECONDS + const k = moveProgress(move, t) + // A slide glides crouched, with no hop; a step hops on walking frames. + if (isSlide(move)) { + drawTile(toWorld(s, move.from).add(PLAYER_LIFT).lerp(to, k), vec2(1), sprite('character_green_duck'), C.white, 0, facingLeft) + return + } const pos = toWorld(s, move.from).add(PLAYER_LIFT).lerp(to, k).add(vec2(0, 0.08 * Math.sin(k * Math.PI))) drawTile(pos, vec2(1), sprite(k < 0.5 ? 'character_green_walk_a' : 'character_green_walk_b'), C.white, 0, facingLeft) return diff --git a/src/render/effects.ts b/src/render/effects.ts index e76ee92..65ff104 100644 --- a/src/render/effects.ts +++ b/src/render/effects.ts @@ -112,6 +112,12 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even placed.add(key.bomb(e.pos)) break } + // Fired (RPG, ICBM): it flies straight from the player, as fast as a kicked bomb slides. + case 'bombFired': { + const tiles = Math.abs(e.to.x - e.from.x) + Math.abs(e.to.y - e.from.y) + fx.slides.set(key.bomb(e.to), { from: e.from, at: now, seconds: tiles * KICK_SECONDS_PER_TILE + 0.08, arc: false }) + break + } case 'bombKicked': { const tiles = Math.abs(e.to.x - e.from.x) + Math.abs(e.to.y - e.from.y) fx.slides.set(key.bomb(e.to), { from: e.from, at: now, seconds: tiles * KICK_SECONDS_PER_TILE + 0.08, arc: false }) @@ -249,6 +255,8 @@ export interface PlayerMove { from: Vec at: number teleport: boolean + /** Tiles from `from` to where the player is now, in a straight line or not. */ + tiles?: number } /** @@ -262,7 +270,26 @@ export interface PlayerPose { /** How long the player's move takes on screen: a step's slide, or a teleport's vanish and reappear. */ export function moveSeconds(move: PlayerMove): number { - return move.teleport ? 2 * TELEPORT_SECONDS : WALK_SECONDS + if (move.teleport) return 2 * TELEPORT_SECONDS + return isSlide(move) ? SLIDE_SECONDS_PER_TILE * move.tiles! : WALK_SECONDS +} + +/** + * A slide: further than a run's 2 tiles, not a teleport. It glides there crouched, slowing as it + * stops, at a speed per tile rather than in a step's time (user report, 2026-10-24: six tiles in a + * step's 0.16 s looked like a few jumps). + */ +export function isSlide(move: PlayerMove): boolean { + return !move.teleport && (move.tiles ?? 1) > 2 +} + +/** How long a slide takes per tile. */ +export const SLIDE_SECONDS_PER_TILE = 0.07 + +/** How far along its move the player is, 0 to 1, `t` seconds in: a slide eases out, a step is even. */ +export function moveProgress(move: PlayerMove, t: number): number { + const k = Math.min(1, Math.max(0, t / moveSeconds(move))) + return isSlide(move) ? 1 - (1 - k) * (1 - k) : k } /** Seconds a teleport's vanishing (and then its reappearing) takes. */ @@ -281,7 +308,8 @@ export function playerTransition( ): { move: PlayerMove | null; pose: PlayerPose } { if (events.some((e) => e.type === 'levelStarted' || e.type === 'levelRestarted')) return { move: null, pose: { kind: 'none', at: now } } const moved = prev.player.x !== next.player.x || prev.player.y !== next.player.y - const move = moved ? { from: { ...prev.player }, at: now, teleport: teleported } : null + const tiles = Math.abs(next.player.x - prev.player.x) + Math.abs(next.player.y - prev.player.y) + const move = moved ? { from: { ...prev.player }, at: now, teleport: teleported, tiles } : null const kind = events.some((e) => e.type === 'playerDied') ? 'hit' : events.some((e) => e.type === 'levelCleared') diff --git a/src/render/sprites.ts b/src/render/sprites.ts index 75bcc9b..d26cd5b 100644 --- a/src/render/sprites.ts +++ b/src/render/sprites.ts @@ -98,6 +98,10 @@ const BOARD = [ 'charge_shield', 'charge_freeze', 'charge_decoy', + 'powerup_rpg', + 'powerup_proximity', + 'powerup_slide', + 'charge_icbm', ] as const export type BoardSprite = (typeof BOARD)[number] @@ -152,7 +156,8 @@ export function wallSprite(level: number, width: number, height: number, p: { x: * 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). + * figure in an empty ring; Freeze a locked padlock; Decoy three pawns; ICBM a bomb with a target + * (tools/compose-icons.sh). */ export const CHARGE_ICONS: Record<ChargeId, BoardSprite> = { pierce: 'powerup_wall_pass', @@ -161,6 +166,7 @@ export const CHARGE_ICONS: Record<ChargeId, BoardSprite> = { shield: 'charge_shield', freeze: 'charge_freeze', decoy: 'charge_decoy', + icbm: 'charge_icbm', } /** @@ -195,8 +201,9 @@ export const GEM_SPRITES: Record<GemColor, BoardSprite> = { * Each power-up's sprite (PLAN.md §13). Those that change something you already have are that * thing with a modifier badge (user request, 2026-10-22): a bomb with +, with expanding arrows * (blast), with a timer (fuse), with an arrow right (kick) or up-right (throw), with ! (Detonator) - * or a shield (Bomb Pass); the Move figure with fast-forward (Run) or an outbox (Teleport); a - * crate with ✕ (Wall Pass); flame with a shield (Flame Pass). The rest by meaning. + * or a shield (Bomb Pass), with a forward arrow (RPG) or a warning sign (Proximity alert); the Move + * figure with fast-forward (Run), an outbox (Teleport) or a two-way arrow (Slide); a crate with ✕ + * (Wall Pass); flame with a shield (Flame Pass). The rest by meaning. */ export const POWER_UP_ICONS: Record<PowerUpKind, BoardSprite> = { bombPlus: 'powerup_bomb_plus', @@ -214,4 +221,7 @@ export const POWER_UP_ICONS: Record<PowerUpKind, BoardSprite> = { throw: 'powerup_throw', teleport: 'powerup_teleport', diagonal: 'powerup_diagonal', + rpg: 'powerup_rpg', + proximity: 'powerup_proximity', + slide: 'powerup_slide', } diff --git a/src/sim/actions.ts b/src/sim/actions.ts index fe2b2dc..0353d90 100644 --- a/src/sim/actions.ts +++ b/src/sim/actions.ts @@ -1,5 +1,5 @@ import { enemyAt } from './enemies' -import { add, bombAt, cellAt, eq, isBurning, isFree, playerCanEnter } from './state' +import { add, bombAt, cellAt, eq, exitOpen, isBurning, isFree, playerCanEnter } from './state' import { DIRS, DIR_LIST, type ActionId, type Bomb, type ChargeId, type GameEvent, type GameState, type Powers, type Vec } from './types' /** @@ -13,6 +13,8 @@ export interface ActionDef { needsTarget: boolean /** The power-up ability it needs (PLAN.md §3.2); only offered once the player has it. */ ability?: keyof Powers + /** Anything else it needs (a charge's launches left). */ + available?: (s: GameState) => boolean validTargets(s: GameState): Vec[] apply(s: GameState, target: Vec | undefined, events: GameEvent[]): void } @@ -27,7 +29,7 @@ function canHoldBomb(s: GameState, p: Vec): boolean { } /** 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 { +function placeBomb(s: GameState, pos: Vec, events: GameEvent[]): Bomb { 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 @@ -36,8 +38,59 @@ function placeBomb(s: GameState, pos: Vec, events: GameEvent[]): void { } // A Power bomb's flame reaches as far as the level is wide: in practice, to the walls. if (bomb.power) bomb.fire = Math.max(s.width, s.height) + if (s.powers.proximity) bomb.proximity = true s.bombs.push(bomb) events.push({ type: 'bombPlaced', pos: { ...pos }, timer: s.stats.fuse }) + return bomb +} + +/** + * Where a bomb fired from the player in direction `d` lands (RPG, ICBM): it flies until the next + * tile is a wall, crate, bomb or enemy (or flame), and lands just before it (user's choice, + * 2026-10-24). Null if the tile next to the player can't hold it. + */ +export function flightEnd(s: GameState, d: Vec): Vec | null { + let p = add(s.player, d) + if (!canHoldBomb(s, p)) return null + while (canHoldBomb(s, add(p, d))) p = add(p, d) + return p +} + +/** The landing tiles of a bomb fired each way, at least `min` tiles from the player. */ +function flightTargets(s: GameState, min: number): Vec[] { + return DIR_LIST.flatMap((d) => { + const p = flightEnd(s, DIRS[d]) + return p && Math.abs(p.x - s.player.x) + Math.abs(p.y - s.player.y) >= min ? [p] : [] + }) +} + +/** The way from the player to a tile in a straight line, one step. */ +function wayTo(s: GameState, target: Vec): Vec { + return { x: Math.sign(target.x - s.player.x), y: Math.sign(target.y - s.player.y) } +} + +/** + * The tiles a slide from the player in direction `d` passes, in order, the last where it stops + * (user's choice, 2026-10-24): it goes on over free floor until the next tile is a wall, crate or + * bomb; it stops on an enemy (crashing into it) and on the open exit. Flame on the way doesn't + * stop it (step() burns the player, unless flame can't hurt them). + */ +export function slidePath(s: GameState, d: Vec): Vec[] { + const path: Vec[] = [] + let p = s.player + for (;;) { + const q = add(p, d) + if (!isFree(s, q)) break + path.push(q) + if (enemyAt(s, q) || (exitOpen(s) && eq(q, s.exit))) break + p = q + } + return path +} + +/** How far Teleport reaches: TELEPORT_RANGE, one more with Run (user request, 2026-10-24). */ +export function teleportRange(s: GameState): number { + return TELEPORT_RANGE + (s.powers.run ? 1 : 0) } function hasCapacity(s: GameState): boolean { @@ -80,6 +133,7 @@ export function applyCharge(s: GameState, kind: ChargeId, events: GameEvent[]): case 'pierce': case 'power': case 'nuke': + case 'icbm': s.boosts[kind] += BOMB_BOOST break case 'shield': @@ -117,7 +171,7 @@ export const ACTIONS: Record<ActionId, ActionDef> = { label: 'Bomb', needsTarget: true, validTargets: bombTargets, - apply: (s, target, events) => placeBomb(s, target!, events), + apply: (s, target, events) => void placeBomb(s, target!, events), }, wait: { id: 'wait', @@ -188,7 +242,7 @@ export const ACTIONS: Record<ActionId, ActionDef> = { const land = add(mid, DIRS[d]) return cellAt(s, mid) !== 'wall' && canHoldBomb(s, land) ? [land] : [] }), - apply: (s, target, events) => placeBomb(s, target!, events), + apply: (s, target, events) => void placeBomb(s, target!, events), }, diagonal: { id: 'diagonal', @@ -213,20 +267,66 @@ export const ACTIONS: Record<ActionId, ActionDef> = { needsTarget: true, ability: 'teleport', validTargets(s) { + const range = teleportRange(s) const tiles: Vec[] = [] - for (let dy = -TELEPORT_RANGE; dy <= TELEPORT_RANGE; dy++) { - for (let dx = -TELEPORT_RANGE; dx <= TELEPORT_RANGE; dx++) { + for (let dy = -range; dy <= range; dy++) { + for (let dx = -range; dx <= range; dx++) { const p = add(s.player, { x: dx, y: dy }) - if ((dx || dy) && Math.abs(dx) + Math.abs(dy) <= TELEPORT_RANGE && playerCanEnter(s, p)) tiles.push(p) + if ((dx || dy) && Math.abs(dx) + Math.abs(dy) <= range && playerCanEnter(s, p)) tiles.push(p) } } return tiles }, apply: (s, target, events) => walkTo(s, target!, events), }, + rpg: { + id: 'rpg', + label: 'RPG', + needsTarget: true, + ability: 'rpg', + // Fired each way, landing 2 tiles away or more (1 is a plain bomb); within the bomb limit. + validTargets: (s) => (hasCapacity(s) ? flightTargets(s, 2) : []), + apply(s, target, events) { + placeBomb(s, target!, events) + events.push({ type: 'bombFired', from: { ...s.player }, to: { ...target! } }) + }, + }, + icbm: { + id: 'icbm', + label: 'ICBM', + needsTarget: true, + available: (s) => s.boosts.icbm > 0, + // Fired as an RPG's, at any distance; it goes off this turn, so the bomb limit doesn't apply. + validTargets: (s) => flightTargets(s, 1), + apply(s, target, events) { + s.boosts.icbm-- + placeBomb(s, target!, events).timer = 0 + events.push({ type: 'bombFired', from: { ...s.player }, to: { ...target! } }) + }, + }, + slide: { + id: 'slide', + label: 'Slide', + needsTarget: true, + ability: 'slide', + // Where each way's slide stops, 2 tiles away or more (1 is a step). + validTargets: (s) => + DIR_LIST.flatMap((d) => { + const path = slidePath(s, DIRS[d]) + return path.length >= 2 ? [path[path.length - 1]!] : [] + }), + apply: (s, target, events) => walkTo(s, target!, events), + }, +} + +/** The tiles a slide to `target` passed before stopping there (step() picks up and burns on them). */ +export function slidePassed(s: GameState, target: Vec): Vec[] { + return slidePath(s, wayTo(s, target)).slice(0, -1) } -export const ACTION_ORDER: readonly ActionId[] = ['move', 'bomb', 'wait', 'run', 'detonate', 'kick', 'throw', 'diagonal', 'teleport'] +export const ACTION_ORDER: readonly ActionId[] = [ + 'move', 'bomb', 'wait', 'run', 'detonate', 'kick', 'throw', 'diagonal', 'teleport', 'rpg', 'icbm', 'slide', +] export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean { return ACTIONS[id].validTargets(s).some((p) => eq(p, target)) @@ -238,5 +338,6 @@ export function isAvailable(s: GameState, id: ActionId): boolean { const ability = ACTIONS[id].ability if (ability && !s.powers[ability]) return false const def = ACTIONS[id] + if (def.available && !def.available(s)) return false return !def.needsTarget || def.validTargets(s).length > 0 } diff --git a/src/sim/danger.ts b/src/sim/danger.ts index 4ccf13d..af55132 100644 --- a/src/sim/danger.ts +++ b/src/sim/danger.ts @@ -1,4 +1,4 @@ -import { detonate, tickBombs } from './explosions' +import { detonate, proximityAlert, tickBombs } from './explosions' import { cloneState } from './state' import type { GameEvent, GameState } from './types' @@ -17,9 +17,14 @@ export interface Danger { export function predictDanger(state: GameState): Danger { const s = cloneState(state) const ignored: GameEvent[] = [] + // Proximity bombs: set off by where the enemies will be after their announced moves (frozen + // ones stay put), this turn and the next. + const enemiesAfter = (k: number) => state.enemies.map((e) => (state.frozen > k ? e.pos : (e.plan[k] ?? e.plan[e.plan.length - 1] ?? e.pos))) + proximityAlert(s, ignored, enemiesAfter(0)) tickBombs(s, ignored) const exploding = detonate(s, ignored) s.turn++ + proximityAlert(s, ignored, enemiesAfter(1)) tickBombs(s, ignored) const later = detonate(s, ignored) return { now: new Set([...exploding, ...state.burning]), next: new Set([...later, ...exploding]) } diff --git a/src/sim/demo.ts b/src/sim/demo.ts index 63c356c..525ad78 100644 --- a/src/sim/demo.ts +++ b/src/sim/demo.ts @@ -37,6 +37,7 @@ export type SceneKind = | 'tapMove' | 'holdBomb' | 'bombButton' | 'wait' | 'autoMove' | 'kill' | 'arrows' | 'escape' | 'chain' | 'lingering' | 'enemies' | 'coins' | 'gem' | 'bombPlus' | 'firePlus' | 'fusePlus' | 'run' | 'kick' | 'throw' | 'teleport' | 'diagonal' | 'detonate' + | 'rpg' | 'proximity' | 'slide' | 'icbm' | 'wallPass' | 'bombPass' | 'flamePass' | 'star' | 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy' | 'exit' | 'doorBlast' @@ -295,6 +296,31 @@ export const SCENES: Record<SceneKind, SceneDef> = { prepare: (s) => ((s.powers.detonate = true), (s.stats.fuse = 6)), shows: (t) => t.events.some((e) => e.type === 'detonated') && kills(t).length > 0, }, + // Added 2026-10-24 (user request), with their power-ups and charge. + rpg: { + caption: 'RPG: press Bomb, then tap where it lands — it flies as far as it can.', + enemies: ['blob', 'drifter'], + prepare: (s) => (s.powers.rpg = true), + shows: (t, seen) => seen.used.has('rpg') && kills(t).length > 0, + }, + proximity: { + caption: 'Proximity alert: your bombs go off as soon as an enemy is in their reach.', + enemies: ['blob', 'seeker'], + prepare: (s) => (s.powers.proximity = true), + shows: (t) => t.events.some((e) => e.type === 'proximityTriggered') && kills(t).length > 0, + }, + slide: { + caption: 'Slide: tap where you stop — you slide until a wall, crate or bomb.', + enemies: ['blob'], + prepare: (s) => (s.powers.slide = true), + shows: (t) => t.action.id === 'slide' && steps(t.prev.player, t.next.player) >= 3, + }, + icbm: { + caption: 'ICBM: press Bomb, then tap where it lands — it goes off at once.', + enemies: ['blob', 'drifter'], + prepare: dropping('icbm', 1), + shows: (t) => t.action.id === 'icbm' && kills(t).length > 0, + }, wallPass: { caption: 'Wall Pass: walk through crates.', enemies: ['blob'], @@ -706,7 +732,8 @@ function rollout(kind: SceneKind, start: GameState, rng: Rng): GameTurn[] | null function reasonable(kind: SceneKind, t: Turn): boolean { const punishing = kind !== 'doorBlast' if (punishing && t.events.some((e) => e.type === 'enemiesSpawned')) return false - if ((t.action.id !== 'bomb' && t.action.id !== 'throw') || !('target' in t.action) || !t.action.target) return true + const placing = t.action.id === 'bomb' || t.action.id === 'throw' || t.action.id === 'rpg' + if (!placing || !('target' in t.action) || !t.action.target) return true const target = t.action.target const bomb = t.next.bombs.find((b) => at(b.pos, target)) if (!bomb) return true @@ -721,8 +748,8 @@ function reasonable(kind: SceneKind, t: Turn): boolean { /** * The gesture that plays `action` in a scene, or null if none does (a step onto a bomb with Bomb - * Pass: a tap there kicks it, or means nothing). A step, a diagonal step, a kick or a detonation is - * a tap on the tile; a bomb is a long press, or the Bomb button and a tap in the scene about it; + * Pass: a tap there kicks it, or means nothing). A step, a diagonal step, a slide, a kick or a detonation is + * a tap on the tile (a slide too); a bomb is a long press, or the Bomb button and a tap in the scene about it; * Wait is its button; Run, Teleport and Throw are their button (Move or Bomb), then the tile. */ function gestureFor(def: SceneDef, s: GameState, action: Action): Gesture | null { @@ -735,7 +762,7 @@ function gestureFor(def: SceneDef, s: GameState, action: Action): Gesture | null return resolvePick(s, button, tile) === action.id ? { kind: 'button', button, tile } : null } -const TAPPED: ReadonlySet<ActionId> = new Set(['move', 'diagonal', 'kick', 'detonate']) +const TAPPED: ReadonlySet<ActionId> = new Set(['move', 'diagonal', 'kick', 'detonate', 'slide']) /** An action and the gesture that plays it in this scene, or null if no gesture does. */ function playOf(def: SceneDef, s: GameState, action: Action | null): Play | null { @@ -744,7 +771,9 @@ function playOf(def: SceneDef, s: GameState, action: Action | null): Play | null } /** Weights for a random action: mostly moving, bombing now and then, sometimes waiting; abilities when held. */ -const WEIGHTS: Partial<Record<ActionId, number>> = { move: 5, bomb: 2, wait: 1, detonate: 3, run: 2, kick: 3, throw: 2, teleport: 2, diagonal: 2 } +const WEIGHTS: Partial<Record<ActionId, number>> = { + move: 5, bomb: 2, wait: 1, detonate: 3, run: 2, kick: 3, throw: 2, teleport: 2, diagonal: 2, rpg: 2, icbm: 3, slide: 3, +} function randomAction(s: GameState, rng: Rng): Action { const choices = (Object.keys(WEIGHTS) as ActionId[]).filter((id) => isAvailable(s, id)) diff --git a/src/sim/enemies.ts b/src/sim/enemies.ts index dddba3e..f9a4f35 100644 --- a/src/sim/enemies.ts +++ b/src/sim/enemies.ts @@ -175,7 +175,7 @@ const DROP_CHANCE: Record<EnemyKind, { coins: number; charge: number }> = { * 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'] +export const LASTING_CHARGES: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'shield', 'icbm'] /** * A Decoy, Freeze or Shield already lying on the board, or already working, makes another one diff --git a/src/sim/explosions.ts b/src/sim/explosions.ts index 1f60b85..f3601ef 100644 --- a/src/sim/explosions.ts +++ b/src/sim/explosions.ts @@ -60,6 +60,20 @@ export function detonate(s: GameState, events: GameEvent[]): Set<number> { return burned } +/** + * Proximity alert (user request, 2026-10-24): each of the player's proximity bombs with an enemy + * in its reach goes off this turn. `enemyTiles`: where the enemies are (the danger overlay passes + * where they are going next). + */ +export function proximityAlert(s: GameState, events: GameEvent[], enemyTiles: Vec[] = s.enemies.map((e) => e.pos)): void { + for (const b of s.bombs) { + if (!b.proximity || b.timer <= 0) continue + if (!blastTiles(s, b).some((t) => enemyTiles.some((e) => e.x === t.x && e.y === t.y))) continue + b.timer = 0 + events.push({ type: 'proximityTriggered', pos: { ...b.pos } }) + } +} + /** Phase 3's countdown: every bomb not placed this turn loses 1 from its timer. */ export function tickBombs(s: GameState, events: GameEvent[]): void { for (const b of s.bombs) { diff --git a/src/sim/picks.ts b/src/sim/picks.ts index 0303437..833c682 100644 --- a/src/sim/picks.ts +++ b/src/sim/picks.ts @@ -13,11 +13,13 @@ import type { ActionId, GameState, Vec } from './types' * Members are in order of preference: a tile two of them can reach goes to the earlier one, so * a kick beats walking onto the bomb with Bomb Pass (walking into a bomb kicks it, if it can * slide), a plain step beats a run, and a diagonal step beats a teleport to the same tile (a - * tile next to you is a step; user report, 2026-10-23). + * tile next to you is a step; user report, 2026-10-23). A teleport beats a slide to the same + * tile (it can't crash or burn). While ICBM launches are left, a far tile is an ICBM rather than + * an RPG bomb (2026-10-24): the commit preview shows the blast going off at once. */ const GROUPS: Partial<Record<ActionId, readonly ActionId[]>> = { - move: ['kick', 'move', 'run', 'diagonal', 'teleport'], - bomb: ['bomb', 'throw', 'detonate'], + move: ['kick', 'move', 'run', 'diagonal', 'teleport', 'slide'], + bomb: ['bomb', 'throw', 'icbm', 'rpg', 'detonate'], } /** The actions a picked one stands for: its group, or just itself. */ @@ -46,13 +48,14 @@ export function canPick(s: GameState, picked: ActionId): boolean { } /** What a tap on a tile means with no button picked (`tapMeaning`). */ -export type TapMeaning = 'wait' | 'detonate' | 'kick' | 'blocked' | 'move' | 'diagonal' | 'walk' | 'teleport' +export type TapMeaning = 'wait' | 'detonate' | 'kick' | 'blocked' | 'move' | 'diagonal' | 'slide' | 'walk' | 'teleport' /** * What a tap on `p` means with nothing picked (main.ts `tapTile`; also the demo's taps), first * match wins: the player waits; one of your bombs, with the Detonator, goes off; a bomb next to * you, with Kick, is kicked; an enemy, any other bomb, or flame that can hurt you is 'blocked' - * (never a move into it); then a step, a diagonal step, a far tile you can walk to ('walk': it + * (never a move into it); then a step, a diagonal step, the tile where a slide stops (with Slide: + * user report, 2026-10-24, it walked there instead), a far tile you can walk to ('walk': it * becomes auto-move's destination), or one you can only teleport to. Null: it means nothing. * Auto-move's own destination is the UI's to check: a tap on it is its next step. */ @@ -63,6 +66,7 @@ export function tapMeaning(s: GameState, p: Vec): TapMeaning | null { if (enemyAt(s, p) || bombAt(s, p) || (isBurning(s, p) && !flameSafe(s))) return 'blocked' if (isValidTarget(s, 'move', p)) return 'move' if (isAvailable(s, 'diagonal') && isValidTarget(s, 'diagonal', p)) return 'diagonal' + if (isAvailable(s, 'slide') && isValidTarget(s, 'slide', p)) return 'slide' if (cellAt(s, p) === 'floor' && playerPath(s, p)) return 'walk' // Out of walking reach (behind a pillar, say) but within Teleport's: one teleport there. if (isAvailable(s, 'teleport') && isValidTarget(s, 'teleport', p)) return 'teleport' diff --git a/src/sim/record.ts b/src/sim/record.ts index 7b6714b..57d621b 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 = 23 +export const SIM_VERSION = 24 /** 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 b3a4313..b70cb5e 100644 --- a/src/sim/state.ts +++ b/src/sim/state.ts @@ -26,11 +26,14 @@ export interface Carry { } export function noPowers(): Powers { - return { run: false, kick: false, throw: false, teleport: false, diagonal: 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, + rpg: false, proximity: false, slide: false, + } } export function noBoosts(): Boosts { - return { pierce: 0, power: 0, nuke: 0 } + return { pierce: 0, power: 0, nuke: 0, icbm: 0 } } /** @@ -39,7 +42,7 @@ export function noBoosts(): Boosts { * 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() + const boosts = n > 0 ? { pierce: n * BOMB_BOOST, power: n * BOMB_BOOST, nuke: n * BOMB_BOOST, icbm: n * BOMB_BOOST } : noBoosts() return createLevel(seed, level, options, { lives: START_LIVES, stats: { ...START_STATS }, coins: 0, boosts, shield: n > 0, powers: noPowers(), star: 0, runTurns: 0, runKills: 0, deaths: 0, diff --git a/src/sim/turn.ts b/src/sim/turn.ts index 60a8a5f..36df56a 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -1,7 +1,7 @@ -import { ACTIONS, applyCharge, isAvailable, isValidTarget } from './actions' +import { ACTIONS, applyCharge, isAvailable, isValidTarget, slidePassed } from './actions' import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies' import { enemyKinds, LAST_LEVEL } from './levelgen' -import { detonate, tickBombs } from './explosions' +import { detonate, proximityAlert, tickBombs } from './explosions' import { add, bombAt, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, PAR_BONUS } from './state' import { stream } from './rng' import { CHARGE_IDS, DIRS, DIR_LIST, isCharge, type Action, type Death, type GameEvent, type GameState, type OfferKind, type Powers, type PowerUpKind, type Vec } from './types' @@ -57,10 +57,14 @@ export function step(prev: GameState, action: Action): StepResult { throw new Error(`invalid target for ${action.id}: ${JSON.stringify(action.target)}`) } const from = s.player + // A slide passes over every tile before where it stops (worked out before the player moves). + const slid = action.id === 'slide' ? slidePassed(s, action.target!) : [] def.apply(s, action.target, events) // A run passes through the tile in between: anything there is picked up, or run into. const passed: Vec | null = action.id === 'run' ? add(from, { x: (s.player.x - from.x) / 2, y: (s.player.y - from.y) / 2 }) : null if (passed) pickUp(s, passed, events) + // A slide picks up whatever it passes over. + for (const p of slid) pickUp(s, p, events) pickUp(s, s.player, events) if (exitOpen(s) && eq(s.player, s.exit)) { const parBonus = s.turn + 1 <= s.par ? PAR_BONUS : 0 @@ -97,6 +101,8 @@ export function step(prev: GameState, action: Action): StepResult { // Contact, first check: the player walked (or ran) into an enemy, or swapped places with one. const walkedInto = enemyAt(s, s.player) ?? (passed ? enemyAt(s, passed) : undefined) if (walkedInto) hurt({ cause: 'walkedIntoEnemy', enemy: walkedInto.kind, turn: s.turn + 1 }) + // A slide over last turn's flame burns (it doesn't stop for it: user's choice, 2026-10-24). + if (s.status === 'playing' && slid.some((p) => s.burning.includes(idx(s, p)))) hurt({ cause: 'lingering', turn: s.turn + 1 }) // Phase 2: enemies carry out their announced intents. Second contact check. if (s.status === 'playing') { @@ -104,6 +110,9 @@ export function step(prev: GameState, action: Action): StepResult { const caughtBy = enemyAt(s, s.player) if (caughtBy) hurt({ cause: 'caughtByEnemy', enemy: caughtBy.kind, turn: s.turn + 1 }) } + // Proximity alert: a bomb with an enemy in its reach goes off this turn (user's choice, + // 2026-10-24: wherever the enemy is now, one that just walked in or one already there). + proximityAlert(s, events) // Phase 3: bombs tick; those at 0 explode, in chains. Last turn's flame is still burning too. const lingering = new Set(s.burning) @@ -282,13 +291,14 @@ export const OFFER_SIZE = 5 */ 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], + ['slide', 6], ['rpg', 6], ['detonator', 9], ['proximity', 5], ['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', + wallPass: 'wallPass', bombPass: 'bombPass', flamePass: 'flamePass', rpg: 'rpg', proximity: 'proximity', slide: 'slide', } /** @@ -330,5 +340,8 @@ function applyPowerUp(s: GameState, kind: PowerUpKind): void { case 'throw': s.powers.throw = true; break case 'teleport': s.powers.teleport = true; break case 'diagonal': s.powers.diagonal = true; break + case 'rpg': s.powers.rpg = true; break + case 'proximity': s.powers.proximity = true; break + case 'slide': s.powers.slide = true; break } } diff --git a/src/sim/types.ts b/src/sim/types.ts index 2622af9..069c30c 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -37,6 +37,8 @@ export interface Bomb { power?: boolean /** Nuke: it also burns every tile within NUKE_RANGE steps, through anything. */ nuke?: boolean + /** Proximity alert: it goes off the turn an enemy is in its reach (the power-up, when placed). */ + proximity?: boolean } export interface Stats { @@ -78,6 +80,7 @@ export const DEFAULT_OPTIONS: RunOptions = { dev: false, immortal: false } export type PowerUpKind = | 'bombPlus' | 'firePlus' | 'fusePlus' | 'run' | 'wallPass' | 'bombPass' | 'flamePass' | 'detonator' | 'star' | 'coinBag' | 'heart' | 'kick' | 'throw' | 'teleport' | 'diagonal' + | 'rpg' | 'proximity' | 'slide' /** * A gem's colour, from the seed. Only how it looks: every level's gem offers the same kind of @@ -122,6 +125,12 @@ export interface Powers { bombPass: boolean /** Flame doesn't hurt. */ flamePass: boolean + /** RPG: Bomb can also fire a bomb in a straight line, as far as it flies (user request, 2026-10-24). */ + rpg: boolean + /** Proximity alert: your bombs go off as soon as an enemy is in their reach. */ + proximity: boolean + /** Slide: Move can also slide in a straight line until a wall, crate or bomb stops you. */ + slide: boolean } /** The enemy types so far (PLAN.md §10). */ @@ -156,9 +165,9 @@ export interface Enemy { * 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 = 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy' +export type ChargeId = 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy' | 'icbm' -export const CHARGE_IDS: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy'] +export const CHARGE_IDS: readonly ChargeId[] = ['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy', 'icbm'] /** * What a gem can offer: a power-up, or a charge (only Developer mode's rainbow gem, to test them @@ -176,6 +185,8 @@ export interface Boosts { pierce: number power: number nuke: number + /** ICBM launches left: a bomb fired as far as it flies, going off that same turn (user request, 2026-10-24). */ + icbm: number } /** A stand-in for the player that chasing enemies go after instead, for a few turns. */ @@ -252,7 +263,8 @@ export interface GameState { } /** The actions the player can pick on the board. */ -export type ActionId = 'move' | 'bomb' | 'wait' | 'run' | 'detonate' | 'kick' | 'throw' | 'teleport' | 'diagonal' +export type ActionId = + | 'move' | 'bomb' | 'wait' | 'run' | 'detonate' | 'kick' | 'throw' | 'teleport' | 'diagonal' | 'rpg' | 'icbm' | 'slide' /** `choose`: one of the gem's offer. It takes no turn. */ export type Action = { id: ActionId; target?: Vec } | { id: 'continue' } | { id: 'choose'; kind: OfferKind } @@ -291,6 +303,10 @@ export type GameEvent = | { type: 'levelCleared'; level: number; turns: number; parBonus: number } | { type: 'levelStarted'; level: number } | { type: 'bombKicked'; from: Vec; to: Vec } + /** An RPG or ICBM bomb flew from the player to where it landed. */ + | { type: 'bombFired'; from: Vec; to: Vec } + /** Proximity alert: an enemy in a bomb's reach set it off. */ + | { type: 'proximityTriggered'; pos: Vec } | { type: 'shieldBroke'; pos: Vec } | { type: 'decoyPlaced'; pos: Vec } | { type: 'decoyGone'; pos: Vec; burned: boolean } diff --git a/src/ui/almanac.ts b/src/ui/almanac.ts index 3f50f6b..51c5276 100644 --- a/src/ui/almanac.ts +++ b/src/ui/almanac.ts @@ -21,7 +21,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', 'diagonal', 'detonator', + 'bombPlus', 'firePlus', 'fusePlus', 'run', 'kick', 'throw', 'teleport', 'diagonal', 'slide', 'rpg', 'detonator', 'proximity', 'wallPass', 'bombPass', 'flamePass', 'star', 'heart', 'coinBag', ] diff --git a/src/ui/clip.ts b/src/ui/clip.ts index 86d0433..e95d771 100644 --- a/src/ui/clip.ts +++ b/src/ui/clip.ts @@ -48,7 +48,7 @@ interface Clip { * choice, 2026-10-24: "when helpful"): the actions, and the power-ups worked by hand. */ const FINGER_KINDS: ReadonlySet<SceneKind> = new Set([ - 'tapMove', 'holdBomb', 'bombButton', 'wait', 'run', 'kick', 'throw', 'teleport', 'diagonal', 'detonate', + 'tapMove', 'holdBomb', 'bombButton', 'wait', 'run', 'kick', 'throw', 'teleport', 'diagonal', 'detonate', 'rpg', 'icbm', 'slide', ]) /** The finger's glide, a tap, and a long press (as in play). */ const GLIDE_SECONDS = 0.35 diff --git a/src/ui/controls.ts b/src/ui/controls.ts index 035d8ed..caa46c4 100644 --- a/src/ui/controls.ts +++ b/src/ui/controls.ts @@ -41,8 +41,8 @@ interface ButtonDef { } const BUTTONS: ButtonDef[] = [ - { id: 'move', label: 'Move', icon: 'icon_move', key: 'M', group: ['move', 'run', 'teleport', 'kick', 'diagonal'] }, - { id: 'bomb', label: 'Bomb', icon: 'bomb', key: 'B', group: ['bomb', 'throw', 'detonate'] }, + { id: 'move', label: 'Move', icon: 'icon_move', key: 'M', group: ['move', 'run', 'teleport', 'kick', 'diagonal', 'slide'] }, + { id: 'bomb', label: 'Bomb', icon: 'bomb', key: 'B', group: ['bomb', 'throw', 'icbm', 'rpg', 'detonate'] }, { id: 'wait', label: 'Wait', icon: 'icon_wait', key: 'Space', group: ['wait'] }, ] @@ -57,6 +57,9 @@ const PASSES: [keyof Powers, PowerUpKind, string][] = [ ['wallPass', 'wallPass', 'Wall Pass'], ['bombPass', 'bombPass', 'Bomb Pass'], ['flamePass', 'flamePass', 'Flame Pass'], + ['rpg', 'rpg', 'RPG'], + ['proximity', 'proximity', 'Proximity alert'], + ['slide', 'slide', 'Slide'], ] /** A top-bar item: a button that opens its info (`data-info`, see ui/info.ts). */ diff --git a/src/ui/info.ts b/src/ui/info.ts index f64baa9..5d9c0bc 100644 --- a/src/ui/info.ts +++ b/src/ui/info.ts @@ -1,4 +1,4 @@ -import { BOMB_BOOST, DECOY_TURNS, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE } from '../sim/actions' +import { BOMB_BOOST, DECOY_TURNS, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE, teleportRange } from '../sim/actions' import { LAST_LEVEL } from '../sim/levelgen' import { bombAt, cellAt, eq, exitOpen, isBurning, PAR_BONUS } from '../sim/state' import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS } from '../sim/turn' @@ -31,7 +31,7 @@ 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.' }, + run: { name: 'Run', text: `Move can also run: 2 tiles in a straight line in one turn. Both tiles must be empty. With Teleport, it teleports ${TELEPORT_RANGE + 1} steps instead of ${TELEPORT_RANGE}.` }, 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.' }, @@ -41,14 +41,18 @@ export const POWER_UP_TEXT: Record<PowerUpKind, { name: string; text: string }> 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.' }, throw: { name: 'Throw', text: 'Bomb can also put a bomb 2 tiles away in a straight line, over a crate, bomb or enemy in between (not a pillar).' }, - teleport: { name: 'Teleport', text: `Move can also take you to any empty tile up to ${TELEPORT_RANGE} steps away, over anything.` }, + teleport: { name: 'Teleport', text: `Move can also take you to any empty tile up to ${TELEPORT_RANGE} steps away, over anything (${TELEPORT_RANGE + 1} with Run).` }, diagonal: { name: 'Diagonal', text: 'Move can also step diagonally, even squeezing between two blocked tiles.' }, + rpg: { name: 'RPG', text: 'Bomb can also fire a bomb in a straight line: it flies until a wall, crate, bomb or enemy is next, and lands just before it.' }, + proximity: { name: 'Proximity alert', text: 'Your bombs go off as soon as an enemy is in their reach — wherever you are standing.' }, + slide: { name: 'Slide', text: 'Move can also slide in a straight line until a wall, crate or bomb stops you, picking up what you pass. An enemy on the way stops you the hard way; flame on the way burns.' }, } /** The scene that shows each power-up, for its clip (PLAN.md §13c); Heart and Coin bag have none. */ export const POWER_UP_CLIP: Partial<Record<PowerUpKind, SceneKind>> = { bombPlus: 'bombPlus', firePlus: 'firePlus', fusePlus: 'fusePlus', run: 'run', detonator: 'detonate', wallPass: 'wallPass', bombPass: 'bombPass', flamePass: 'flamePass', star: 'star', kick: 'kick', throw: 'throw', teleport: 'teleport', diagonal: 'diagonal', + rpg: 'rpg', proximity: 'proximity', slide: 'slide', } /** What each charge does once picked up (user request, 2026-10-23: they work at once, as a temporary change). */ @@ -59,6 +63,7 @@ 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. 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.` }, + icbm: { name: 'ICBM', text: `${BOMB_BOOST} launches: Bomb fires a bomb as far as it flies, as an RPG's, and it goes off the moment it lands. Your bomb limit doesn't count.` }, } /** @@ -70,6 +75,7 @@ export function chargeLeft(s: GameState, id: ChargeId): number | true | 0 { case 'pierce': case 'power': case 'nuke': + case 'icbm': return s.boosts[id] case 'shield': return s.shield || 0 @@ -119,7 +125,7 @@ export function modeLines(s: GameState): string[] { /** Each ability or pass, as the power-up that gives it (for its icon and name). */ export const PASS_KIND: Record<keyof Powers, PowerUpKind> = { run: 'run', kick: 'kick', throw: 'throw', teleport: 'teleport', diagonal: 'diagonal', detonate: 'detonator', wallPass: 'wallPass', - bombPass: 'bombPass', flamePass: 'flamePass', + bombPass: 'bombPass', flamePass: 'flamePass', rpg: 'rpg', proximity: 'proximity', slide: 'slide', } export function hudInfo(item: HudItem, s: GameState): Info { @@ -192,6 +198,7 @@ export function hudInfo(item: HudItem, s: GameState): Info { case 'shield': case 'freeze': case 'decoy': + case 'icbm': return { icon: CHARGE_ICONS[item], title: chargeTitle(s, item), body: [CHARGE_TEXT[item].text], clip: item } default: { const kind = PASS_KIND[item] @@ -310,6 +317,7 @@ function chargeTitle(s: GameState, id: ChargeId): string { 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'}` + if (id === 'icbm') return `${name}: ${left} ${left === 1 ? 'launch' : 'launches'} left` return `${name}: ${turns(left)} left` } @@ -330,22 +338,28 @@ export function actionInfo(s: GameState, picked: ActionId): Info { case 'run': case 'teleport': case 'kick': - case 'diagonal': { + case 'diagonal': + case 'slide': { const body = ['Tap a highlighted tile: a step to a tile next to you.'] if (s.powers.run) body.push('Run: 2 tiles in a straight line, if both are empty.') - if (s.powers.teleport) body.push(`Teleport: any empty tile up to ${TELEPORT_RANGE} steps away, over anything.`) + if (s.powers.teleport) body.push(`Teleport: any empty tile up to ${teleportRange(s)} 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.powers.diagonal) body.push('Diagonal: a diagonal step, even between two blocked tiles.') + if (s.powers.slide) body.push('Slide: the tile where a slide that way stops, by a wall, crate or bomb.') // Its clip: the move that was picked (a Run, a Teleport...), or plain steps. return { icon: 'icon_move', title: 'Move', body, clip: picked === 'move' ? 'tapMove' : picked } } case 'bomb': case 'throw': - case 'detonate': { + case 'detonate': + case 'rpg': + case 'icbm': { const body = [ `Tap a highlighted tile next to you. The bomb goes off after ${turns(s.stats.fuse)} of yours; its blast reaches ${s.stats.fire} ${s.stats.fire === 1 ? 'tile' : 'tiles'} each way. Up to ${s.stats.capacity} on the board at once.`, ] if (s.powers.throw) body.push('Throw: or 2 tiles away in a straight line, over a crate, bomb or enemy (not a pillar).') + if (s.powers.rpg) body.push('RPG: or as far as it flies, landing before the first wall, crate, bomb or enemy.') + if (s.boosts.icbm > 0) body.push(`ICBM: ${s.boosts.icbm} ${s.boosts.icbm === 1 ? 'launch' : 'launches'} left — a bomb fired as far as it flies that goes off at once. While any are left, a far tile is an ICBM.`) 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'}.`) diff --git a/test/animation.test.ts b/test/animation.test.ts index 5f2b01f..acf54aa 100644 --- a/test/animation.test.ts +++ b/test/animation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { playerTransition } from '../src/render/effects' +import { isSlide, moveProgress, moveSeconds, playerTransition, SLIDE_SECONDS_PER_TILE, WALK_SECONDS } from '../src/render/effects' import { step } from '../src/sim/turn' import { board, v } from './helpers' @@ -16,7 +16,7 @@ describe('the player always moves on screen', () => { const r = step(s, { id: 'move', target: v(2, 1) }) expect(r.state.status).toBe('dead') const t = playerTransition(s, r.state, r.events, false, 10) - expect(t.move).toEqual({ from: v(1, 1), at: 10, teleport: false }) + expect(t.move).toEqual({ from: v(1, 1), at: 10, teleport: false, tiles: 1 }) expect(t.pose.kind).toBe('hit') }) @@ -38,7 +38,7 @@ describe('the player always moves on screen', () => { ######`) s.powers.teleport = true const tele = step(s, { id: 'teleport', target: v(4, 1) }) - expect(playerTransition(s, tele.state, tele.events, true, 10).move).toEqual({ from: v(1, 1), at: 10, teleport: true }) + expect(playerTransition(s, tele.state, tele.events, true, 10).move).toEqual({ from: v(1, 1), at: 10, teleport: true, tiles: 3 }) const bomb = step(s, { id: 'bomb', target: v(2, 1) }) const t = playerTransition(s, bomb.state, bomb.events, false, 10) expect(t.move).toBeNull() @@ -55,3 +55,18 @@ describe('the player always moves on screen', () => { expect(playerTransition(cleared, next.state, next.events, false, 10).move).toBeNull() }) }) + +describe('a slide glides', () => { + it('takes longer the further it goes, eases out, and a step or run is unchanged', () => { + const step1 = { from: v(1, 1), at: 0, teleport: false, tiles: 1 } + const slide6 = { from: v(1, 1), at: 0, teleport: false, tiles: 6 } + expect(isSlide(step1)).toBe(false) + expect(isSlide({ ...step1, tiles: 2 })).toBe(false) + expect(isSlide(slide6)).toBe(true) + expect(moveSeconds(slide6)).toBeCloseTo(6 * SLIDE_SECONDS_PER_TILE) + expect(moveSeconds(step1)).toBe(WALK_SECONDS) + // Halfway in time, more than halfway there; then at rest at the end. + expect(moveProgress(slide6, moveSeconds(slide6) / 2)).toBeGreaterThan(0.5) + expect(moveProgress(slide6, moveSeconds(slide6))).toBe(1) + }) +}) diff --git a/test/demo.test.ts b/test/demo.test.ts index c11301e..1ba5a28 100644 --- a/test/demo.test.ts +++ b/test/demo.test.ts @@ -15,7 +15,7 @@ function controlsTake(s: GameState, g: Gesture, destination: Vec | null): Action const safe = (p: Vec) => safeStep(s, predictDanger(s), p) if (g.kind === 'tap') { const means = tapMeaning(s, g.tile) - return means === 'move' || means === 'diagonal' || means === 'kick' || means === 'detonate' || means === 'teleport' + return means === 'move' || means === 'diagonal' || means === 'kick' || means === 'detonate' || means === 'teleport' || means === 'slide' ? { id: means, target: g.tile } : null } diff --git a/test/enemies.test.ts b/test/enemies.test.ts index 11403a6..622067f 100644 --- a/test/enemies.test.ts +++ b/test/enemies.test.ts @@ -672,8 +672,9 @@ describe('charge drops are never wasted', () => { expect(dropped((s) => (s.shield = true))).not.toContain('shield') }) - it('Pierce, Power and Nuke still drop when one lies there: they add up', () => { + it('Pierce, Power, Nuke and ICBM still drop when one lies there: they add up', () => { expect(dropped((s) => s.drops.push({ pos: v(2, 1), kind: 'pierce' }))).toContain('pierce') - expect(dropped(() => {})).toEqual(new Set(['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy'])) + expect(dropped((s) => s.drops.push({ pos: v(2, 1), kind: 'icbm' }))).toContain('icbm') + expect(dropped(() => {})).toEqual(new Set(['pierce', 'power', 'nuke', 'shield', 'freeze', 'decoy', 'icbm'])) }) }) diff --git a/test/new-powers.test.ts b/test/new-powers.test.ts new file mode 100644 index 0000000..2f09736 --- /dev/null +++ b/test/new-powers.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest' +import { ACTIONS, BOMB_BOOST, isAvailable, TELEPORT_RANGE } from '../src/sim/actions' +import { predictDanger } from '../src/sim/danger' +import { idx } from '../src/sim/state' +import { step } from '../src/sim/turn' +import { board, v } from './helpers' + +// The power-ups and charge added 2026-10-24 (user request; the rules are the user's choices). + +describe('Run extends Teleport', () => { + it(`with Run, Teleport reaches ${TELEPORT_RANGE + 1} steps instead of ${TELEPORT_RANGE}`, () => { + const s = board(` + ######## + #P.....# + ########`) + s.powers.teleport = true + expect(ACTIONS.teleport.validTargets(s)).not.toContainEqual(v(5, 1)) + s.powers.run = true + expect(ACTIONS.teleport.validTargets(s)).toContainEqual(v(5, 1)) + expect(ACTIONS.teleport.validTargets(s)).not.toContainEqual(v(6, 1)) + }) +}) + +describe('RPG', () => { + it('fires a bomb that lands just before the first wall, crate, bomb or enemy, 2 tiles away or more', () => { + const s = board(` + ######### + #P....+.# + #.####### + #.......# + #B#######`) + s.powers.rpg = true + // Right: lands before the crate at x = 6. Down: lands before the Blob at y = 4. + expect(ACTIONS.rpg.validTargets(s)).toEqual(expect.arrayContaining([v(5, 1), v(1, 3)])) + const { state, events } = step(s, { id: 'rpg', target: v(5, 1) }) + expect(state.bombs.map((b) => b.pos)).toEqual([v(5, 1)]) + expect(state.bombs[0]!.timer).toBe(s.stats.fuse) + expect(events).toContainEqual({ type: 'bombFired', from: v(1, 1), to: v(5, 1) }) + }) + + it('needs room in the bomb limit, and the tile next to the player free', () => { + const s = board(` + ####### + #P1...# + #######`) + s.powers.rpg = true + expect(ACTIONS.rpg.validTargets(s)).toEqual([]) + }) +}) + +describe('ICBM', () => { + it(`gives ${BOMB_BOOST} launches; each lands as an RPG bomb and goes off that turn, outside the bomb limit`, () => { + const s = board(` + ######### + #P....B+# + #########`) + s.drops.push({ pos: v(2, 1), kind: 'icbm' }) + let state = step(s, { id: 'move', target: v(2, 1) }).state + expect(state.boosts.icbm).toBe(BOMB_BOOST) + expect(isAvailable(state, 'icbm')).toBe(true) + state.stats.capacity = 0 + // The Blob is at x = 6 (it stays put): the bomb lands before it, at x = 5, and its blast gets it. + expect(ACTIONS.icbm.validTargets(state)).toContainEqual(v(5, 1)) + const r = step(state, { id: 'icbm', target: v(5, 1) }) + expect(r.events.some((e) => e.type === 'exploded')).toBe(true) + expect(r.state.enemies).toEqual([]) + expect(r.state.bombs).toEqual([]) + expect(r.state.boosts.icbm).toBe(BOMB_BOOST - 1) + }) + + it('is not there once its launches are spent', () => { + const s = board(` + ##### + #P..# + #####`) + expect(isAvailable(s, 'icbm')).toBe(false) + }) +}) + +describe('Proximity alert', () => { + it('sets off a bomb the turn an enemy walks into its reach', () => { + const s = board(` + ######## + #P.5..B# + ########`) + s.powers.proximity = true + // The player's bomb (timer 5) gets the flag when placed; mark this one as theirs. + s.bombs[0]!.proximity = true + // Blast 1: the bomb at x = 3 reaches x = 4, where the Blob steps. + s.enemies[0]!.plan = [v(5, 1), v(4, 1)] + let state = step(s, { id: 'wait' }).state + expect(state.bombs).toHaveLength(1) + const r = step(state, { id: 'wait' }) + const events = r.events + state = r.state + expect(events).toContainEqual({ type: 'proximityTriggered', pos: v(3, 1) }) + expect(state.enemies).toEqual([]) + }) + + it('also when the enemy is already in reach as the bomb is placed', () => { + const s = board(` + ####### + #P.B..# + #######`) + s.powers.proximity = true + const { state } = step(s, { id: 'bomb', target: v(2, 1) }) + expect(state.enemies).toEqual([]) + }) + + it('the danger overlay shows it ahead, from the enemies’ next moves', () => { + const s = board(` + ######## + #P.5..B# + ########`) + s.bombs[0]!.proximity = true + s.enemies[0]!.plan = [v(5, 1), v(4, 1)] + // Not this turn (the Blob is still out of reach), but next turn. + expect(predictDanger(s).now.has(idx(s, v(3, 1)))).toBe(false) + expect(predictDanger(s).next.has(idx(s, v(3, 1)))).toBe(true) + }) + + it('leaves a bomb placed without it alone', () => { + const s = board(` + ######## + #P.5..B# + ########`) + s.enemies[0]!.plan = [v(4, 1)] + expect(step(s, { id: 'wait' }).state.enemies).toHaveLength(1) + }) +}) + +describe('Slide', () => { + it('slides until a wall, crate or bomb is next, picking up what it passes', () => { + const s = board(` + ######### + #P.$..+.# + #########`) + s.powers.slide = true + expect(ACTIONS.slide.validTargets(s)).toEqual([v(5, 1)]) + const { state } = step(s, { id: 'slide', target: v(5, 1) }) + expect(state.player).toEqual(v(5, 1)) + expect(state.coins).toBe(1) + }) + + it('crashes into an enemy on the way', () => { + const s = board(` + ######## + #P..B..# + ########`) + s.powers.slide = true + expect(ACTIONS.slide.validTargets(s)).toEqual([v(4, 1)]) + expect(step(s, { id: 'slide', target: v(4, 1) }).state.status).toBe('dead') + }) + + it('is burned by flame on the way, unless flame can’t hurt', () => { + const s = board(` + ######## + #P.....# + ########`) + s.powers.slide = true + s.burning = [idx(s, v(3, 1))] + expect(step(s, { id: 'slide', target: v(6, 1) }).state.status).toBe('dead') + s.powers.flamePass = true + expect(step(s, { id: 'slide', target: v(6, 1) }).state.status).toBe('playing') + }) + + it('stops on the open exit', () => { + const s = board(` + ######## + #P..E..# + ########`) + s.powers.slide = true + expect(ACTIONS.slide.validTargets(s)).toEqual([v(4, 1)]) + expect(step(s, { id: 'slide', target: v(4, 1) }).state.status).toBe('cleared') + }) +}) diff --git a/test/powerups.test.ts b/test/powerups.test.ts index 890e4f0..a7f1a3d 100644 --- a/test/powerups.test.ts +++ b/test/powerups.test.ts @@ -53,7 +53,7 @@ describe('the 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 } + s.powers = { run: true, kick: true, throw: true, teleport: true, diagonal: true, detonate: true, wallPass: true, bombPass: false, flamePass: true, rpg: true, proximity: true, slide: true } for (let level = 1; level <= 30; level++) { s.level = level const offer = drawOffer(s) @@ -64,7 +64,7 @@ describe('the offer', () => { 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 } + s.powers = { run: true, kick: true, throw: true, teleport: true, diagonal: true, detonate: true, wallPass: true, bombPass: true, flamePass: true, rpg: true, proximity: true, slide: true } // Left: Bomb+, Fire+, Fuse+, Star, Heart, Coin bag. expect(drawOffer(s)).toHaveLength(OFFER_SIZE) }) @@ -305,7 +305,7 @@ describe('passes', () => { ##### #P1.# #####`, { capacity: 2 }), star: 0 } - s.powers = { run: true, kick: true, throw: true, teleport: true, diagonal: 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, rpg: true, proximity: true, slide: true } const dead = step(s, { id: 'wait' }).state expect(dead.deaths).toBe(1) const again = step(dead, { id: 'continue' }).state diff --git a/tools/compose-icons.sh b/tools/compose-icons.sh index 6045045..888fdac 100644 --- a/tools/compose-icons.sh +++ b/tools/compose-icons.sh @@ -57,6 +57,14 @@ badge "$K/fireball.png" "$B/shield.png" powerup_flame_pass badge "$K/bomb.png" "$B/skull.png" charge_nuke ink "$I/locked.png" charge_freeze ink "$B/pawns.png" charge_decoy +# The power-ups and charge added 2026-10-24 (user request): RPG, a bomb with a forward arrow; +# Proximity alert, a bomb with a warning sign; Slide, the Move figure with a two-way arrow; the +# ICBM charge, a bomb with a target. +badge "$K/bomb.png" "$I/forward.png" powerup_rpg +badge "$K/bomb.png" "$I/warning.png" powerup_proximity +badge "$OUT/icon_move.png" "$B/arrow_horizontal.png" powerup_slide +badge "$K/bomb.png" "$I/target.png" charge_icbm + # 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 \ diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index 2787087..4394f59 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -69,11 +69,12 @@ const SHOTS = [ // 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: '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') }, + // Seed 107: a bomb by the gem's crate, out of the way while it goes off, then back: the gem + // uncovered beside the player... (found by searching random play with the sim, 2026-10-24; the + // old seed-55 moves no longer reached a gem) + { name: 'gem', query: 'seed=107', act: press('KeyS', 'KeyB', 'KeyS', 'Space', 'KeyW', 'Space', 'Space', 'Space', 'KeyS', 'KeyS') }, + // ...then onto it: the choice of power-ups. + { name: 'gem-offer', query: 'seed=107', act: press('KeyS', 'KeyB', 'KeyS', 'Space', 'KeyW', 'Space', 'Space', 'Space', 'KeyS', 'KeyS', 'KeyD') }, // Developer mode: the rainbow gem uncovered right of the player, and the mode's tag in the top bar... { name: 'dev', query: 'dev', act: async () => {} }, // ...and taking it: every power-up on offer.