explodorama / commit
The shop; the ICBM flies; Diagonal widens Teleport; dialogs open at the top
CHANGELOG.md | 4 +- PLAN.md | 44 ++++++++++++----- README.md | 27 +++++++--- decisions.md | 78 +++++++++++++++++++++++++++++ docs/screenshots/shop.png | Bin 0 -> 191886 bytes src/main.ts | 16 +++++- src/render/animate.ts | 5 +- src/render/board.ts | 32 ++++++++++-- src/render/effects.ts | 50 ++++++++++++++----- src/sim/actions.ts | 11 ++++- src/sim/demo.ts | 2 +- src/sim/record.ts | 2 +- src/sim/shop.ts | 82 +++++++++++++++++++++++++++++++ src/sim/state.ts | 2 +- src/sim/turn.ts | 26 ++++++++-- src/sim/types.ts | 20 +++++++- src/style.css | 80 +++++++++++++++++++++++++++++- src/ui/dialog.ts | 12 ++++- src/ui/info.ts | 9 ++-- src/ui/shop.ts | 122 ++++++++++++++++++++++++++++++++++++++++++++++ test/helpers.ts | 2 +- test/new-powers.test.ts | 18 +++++++ test/shop.test.ts | 83 +++++++++++++++++++++++++++++++ tools/screenshots.mjs | 3 ++ 24 files changed, 676 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 281e921..4923871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ 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 shop**, after every 3rd level: 4 power-ups and charges, priced by rarity, with a restock that gets dearer. Version 26 (with Diagonal widening Teleport): runs saved before don't resume. +- **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; with Diagonal, its reach is a square. 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. @@ -129,6 +130,7 @@ saved before the change is dropped and the next visit starts a new run. - Dialogs never run off the bottom of a phone's screen: only their middle scrolls, and their buttons always show. - Long dialogs (the almanac, the rainbow gem's offer) scroll with a finger again. +- Every dialog opens scrolled to the top: it used to keep where the last one was scrolled to. - The player is drawn a little higher, so their feet never touch the line between tiles. - A top-bar number bumping at the edge of its row (Turn) is no longer cut off. - Moving off the auto-move path now clears the flag. diff --git a/PLAN.md b/PLAN.md index c24cf0f..2276cee 100644 --- a/PLAN.md +++ b/PLAN.md @@ -160,7 +160,7 @@ 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 (*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`). | +| **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; and with Diagonal a diagonal step counts as one, a square of reach, as it reached every diagonal step already — user's choice, same day, so no order of picks wastes one*), 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. | @@ -187,7 +187,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. | +| **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. *Since 2026-10-24 (user request)* it is seen flying: straight from the player, speeding up, with a smoke trail (0.25 s + 0.05 s a tile; at first a thrown arc, which looked like a throw), the turn's blasts and all they do shown only when it lands (`flights`, `blastAt` in effects.ts; the sim is unchanged). | | ~~**Line**~~ | *Removed 2026-10-23* (not in the user's new list). | | ~~**Fuse +1 / Fuse −1**~~ | *Removed 2026-10-22 (user request).* | @@ -344,14 +344,27 @@ are in git history and decisions.md.) - **Coin sources:** some enemy types always drop coins, others sometimes; occasional coins under soft blocks; Coin bag pickups; a **par bonus** for finishing a level within its turn budget. -- **Shop after every level** (so the first shop follows level 1). A random selection, e.g. - 4 offers, one paid reroll: - - Stats: Bomb +1, Fire +1, Fuse +1, Heart - - Abilities: Run, Detonator, Bomb Pass, Wall Pass - - Action charges: any from §3.3, plus **Cleanse** (removes the current curse) - - Run modifiers: **Magnet** (collect drops within 1 tile), **Lucky** (higher drop chance), - **Map** (reveals the exit and hidden pickup each level), **Coin multiplier** -- Shop offers come from the seeded RNG (§12), so a seed always offers the same items. +- *Built 2026-10-24 (the user's choices of the options offered):* **the shop opens every few + levels** — after levels 3, 6, 9, 12, 15 and 18 (`SHOP_EVERY` 3, a choice made here; not after + the last) — when the player goes on from the level-cleared banner, before the next level + (`sim/shop.ts`, `ui/shop.ts`). + - **It sells power-ups and charges:** `SHOP_SIZE` 4 items, drawn without repeats by the gem's + weights (`POWER_UP_WEIGHTS`), charges at weight 7 each; no power-up already held, and no Coin + bag (coins for coins). + - **Prices by rarity** (`PRICES`): Bomb +1 / Fire +1 6, Fuse +1 8, most abilities and passes 10, + Slide / RPG / Detonator 12, Flame Pass 14, Teleport / Proximity alert 16, Heart 18, Star 10; + charges 6–9. To tune by playtesting. + - **Restock:** 2 coins, one more each time in the same shop (`rerollPrice`); a new draw of 4. + - Buying (`buy`) and restocking (`reroll`) are actions of the run record that take no turn; a + bought item works at once. **Next level** is the `continue` action, as from the banner. + - The draws come from the seed, the level and the restock count, so a seed always offers the + same items. + - Its dialog is like the gem's offer: a clip of the selected item above the cards, which show + their prices; the selected card has its own Buy button (or "Need N more"), and Restock and + Next level share one short row (*since 2026-10-24, user report: three full-width buttons left + the items too little room*); it can't be closed otherwise. +- Not (yet): the plan's run modifiers (Magnet, Lucky, Map, Coin multiplier) and Cleanse, which + waits for curses (§6). - Purchases last for this run only (decision 7). --- @@ -614,6 +627,15 @@ settings, sound, and most action charges) are still open. --- +### Animation timing (user's guidance, 2026-10-24) + +Most turns animate in a step's time (0.16 s), so play stays quick. **A rare or involved turn — +a charge at work, an ICBM, something that changes a lot at once — may take longer** and animate +a more complex change of state in stages (the ICBM flies, then its blast plays out), which gives +actions more room. The sim stays instant; only the drawing waits (`blastAt`-style holds in +effects.ts). Input is still never locked (decisions.md 2026-10-22): a quick next tap starts the +next turn over the end of the last one. + ## 13a. The demo (user request, 2026-10-23; built) Some players never played the original, so the game opens with a demo: generated scenes, @@ -922,7 +944,7 @@ tools/ compose-icons.sh (composed sprites and the app icons), sc tap far = auto-move (§3.1). 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, curses from level 2. (The Keep/Fresh death choice was dropped 2026-10-24.) + (2026-10-20). ✅ the shop (2026-10-24, every 3rd level, §8); ⏳ curses from level 2. (The Keep/Fresh death choice was dropped 2026-10-24.) 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); ✅ installable with offline play (2026-10-23); ✅ the demo before the game (2026-10-23, §13a); ⏳ sounds (Kenney pack, §13), remaining diff --git a/README.md b/README.md index 579975e..4c276a3 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@ 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 -seven charges that work when picked up, a hidden gem on every level that offers a choice of power-ups, the exit, the 20-level run with its ending, and saving -mid-run work. The shop, curses and more enemies are planned — +seven charges that work when picked up, a hidden gem on every level that offers a choice of power-ups, the shop every 3rd level, the exit, the 20-level run with its ending, and saving +mid-run work. Curses, sounds and more enemies are planned — see [PLAN.md](PLAN.md). -Version: **24**, shown at the bottom of the menu with the commit the game was built from (a link +Version: **26**, 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 @@ -232,7 +232,7 @@ never a Freeze or Decoy, which would have nothing to work on. And no enemy drops or Shield while one already lies on the board or is working (a decoy out, enemies frozen, a shield up): a second one would be wasted. Pierce, Power and Nuke add up, so they always can. Drops never share a tile, and never land on the uncovered exit: 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). +open, so pick them up soon. Coins are spent in **the shop** (below). ### The exit @@ -272,8 +272,8 @@ 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). **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. | +| **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. **With Diagonal as well, a square:** a diagonal step counts as one, so it reaches up to 3 across and 3 down at once (a 7 × 7 square; 9 × 9 with Run too). | +| **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). With Teleport, it widens Teleport's reach to a square. 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. | @@ -290,6 +290,21 @@ make Move and Bomb do more (**?** then the button says how). The Detonator adds 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. +### The shop + +After every **3rd level** (3, 6, 9, 12, 15 and 18), going on from the level-cleared banner opens +**the shop** before the next level. It has **4 items**: power-ups (none you already hold, and no +Coin bag) and charges, drawn with the gem's rarity weights, each with its **price** — common ones +cheap (Bomb +1 and Fire +1 cost 6), rare ones dear (Teleport and Proximity alert 16, Heart 18), +charges 6–9. Tap an item to select it (a clip above shows it in action), then its **Buy** button +(or, when it's too dear, how many coins you still need); what you buy works at once and lasts for +the rest of the run. **Restock** draws 4 new items for 2 coins, +then 3, 4… in the same shop. **Next level** leaves. Shopping takes no turns. What a shop offers +comes from the seed, so the same dungeon offers the same things. Nothing carries over between +runs: leftover coins only count on the high-score board. + +<img src="docs/screenshots/shop.png" alt="The shop: 5 coins, a clip above the items with their prices, Bomb +1 selected with its own button (1 more coin needed), and Restock and Next level side by side" width="260"> + ### Lives You have **3 lives**. Caught in a blast or touched by an enemy, you lose one and the level starts diff --git a/decisions.md b/decisions.md index 4d07a1d..6470b2c 100644 --- a/decisions.md +++ b/decisions.md @@ -1619,3 +1619,81 @@ questions. What is built already matches: a death with lives left restarts the l seed with everything the run had, and game over's Same dungeon starts the seed again from level 1 with nothing carried over. The rest of the plan no longer mentions it (§0, §5, §5b, §9, the run summary, the certificate, §14, the architecture and the milestones). + +### 2026-10-24 — The shop + +User request: the next thing, the shop. The user chose from the options offered: + +- **Every few levels**, not after each one: after levels 3, 6, 9, 12, 15 and 18. The interval (3) + is my reading of "every few"; it is one constant (`SHOP_EVERY`). It opens when the player goes on + from the level-cleared banner, before the next level starts, and **Next level** leaves it. +- **Power-ups and charges** (recommended): 4 items, drawn by the gem's rarity weights, charges at + weight 7 (about an uncommon power-up); never a power-up already held, nor a Coin bag. +- **Prices by rarity** (recommended): 6 for Bomb +1 and Fire +1 up to 16 for Teleport and Proximity + alert and 18 for a Heart; charges 6–9. A first guess to tune: the test run found to reach the + shop earned 5 coins in 3 levels (one par bonus, no drops picked up), so prices may well be + too high. +- **Restock, getting dearer** (recommended): 2 coins, then 3, 4… within a shop. + +How it works: `continue` from a cleared level that is due a shop opens it (`state.shop`); `buy` +and `reroll` are new actions in the run record, taking no turn; `continue` again starts the next +level. The draw is seeded by the seed, level and restock count. The dialog follows the gem's +offer (select a card, its clip plays above, then Buy), with buttons that keep it open +(`keepOpen` in dialog.ts) and are greyed when unaffordable. A saved run closed in the shop reopens +in it. `SIM_VERSION` 25 (going on from level 3 now opens the shop). Tests: when it opens and +leaves, the draw (no held power-up, no Coin bag, seeded), buying (coins, at once, kept, once only), +too dear, the dearer restock, no board actions while open. Checked in a browser: reaching the shop +after level 3, selecting, restocking (5 → 3 coins), leaving to level 4; a new `shop` screenshot. + +### 2026-10-24 — The shop leaves room for its items + +User report: the shop's three big buttons (Buy, Restock, Next level, stacked) and its clip left +little room for the items, so it needed scrolling all the time. Now the **selected item has its +own Buy button**, inside its card ("Buy for 6", or "Need 1 more" greyed when too dear), and the +dialog's buttons, **Restock and Next level, share one short row**; the shop's clip is smaller (at +most 200 px wide, from 300). The cards are no longer buttons (a button can't hold another), but +selectable boxes that the keyboard reaches too (Enter or Space). On a 390 × 844 phone the four +items now show with only the last one's bottom lines below the edge, where before only about two +did. + +### 2026-10-24 — The ICBM flies + +User request: the ICBM was instant; make it a smoothly flying bomb. The rule stays (it goes off +the turn it is fired, so nothing can step out of the way); only the drawing changes. Since the +bomb has already exploded in the state the screen shows, the effects keep a **flight** (from the +player to where it lands: a bomb in a high arc, turning once, 0.3 s plus 0.06 s per tile) and a +time, `blastAt`, when the turn's blasts show. Until then the flame, the lingering flame, crates +bursting, enemies dying, drops and gems burned or uncovered, the exit opening and enemies let out +are held back — crates and enemies stay drawn as they were — and then play as usual. The danger +overlay already shows where the blast will land while the bomb flies. Checked on a video of a +launch on a test board: the bomb arcs across the row and the blast starts as it lands. + +### 2026-10-24 — Longer animations for rare or involved turns + +User's guidance: "some turns can take longer than others. If we have a charge or something rare or +involved, it's ok to take a bit more time and animate a more complex change of state." Recorded +in PLAN.md §13 (Animation timing) as a rule for what comes next: ordinary turns stay at a step's +pace; rare ones may play out in stages, the sim still settling the turn at once and the drawing +holding back what comes later, as the ICBM's flight does. Input stays unlocked, as decided on +2026-10-22; the longer an animation, the more a quick tap can start the next turn over it — to +watch for, and lock only if it looks wrong. First use beyond the flight itself: an ICBM that +catches the player now shows the hit pose when it lands, not when it is fired. + +### 2026-10-24 — The ICBM speeds straight, trailing smoke + +User report: despite the name, the ICBM flew in a curve, as if thrown. It now flies straight along +its line, picking up speed (its distance grows with the square of the time) — 0.25 s plus 0.05 s +per tile — trailing seven puffs of grey smoke that grow and fade behind it; no arc, no spin. +Checked on a video of a launch: the bomb leaves the player slowly, speeds along the row with the +smoke behind it, and the blast starts as it arrives. + +### 2026-10-24 — Diagonal widens Teleport into a square + +User question: with Teleport held, what should buying Diagonal do? Teleport (3 steps across plus +down, over anything) already reached every diagonal step, squeezes included, so Diagonal added +only a one-tap diagonal step and the Q E Z C keys. Of the two options offered — widen Teleport, or +stop offering Diagonal to a Teleport holder — the user chose to widen it, "so it doesn't waste a +power-up if the order is weird": with Diagonal, Teleport counts a diagonal step as one +(`teleportSteps`), so its reach is a square, 7 × 7 (9 × 9 with Run too), instead of a diamond. +Run gives Teleport length, Diagonal gives it shape. Diagonal's own steps and keys are unchanged. +`SIM_VERSION` 26. Test: a tile 3 across and 3 up is out of reach without Diagonal, in with it. diff --git a/docs/screenshots/shop.png b/docs/screenshots/shop.png new file mode 100644 index 0000000..39d07a0 Binary files /dev/null and b/docs/screenshots/shop.png differ diff --git a/src/main.ts b/src/main.ts index db872c6..be06713 100644 --- a/src/main.ts +++ b/src/main.ts @@ -36,6 +36,7 @@ import { createControls } from './ui/controls' import { closeDialog, dialogOpen, openDialog, setDialogInDemo, type DialogButton } from './ui/dialog' import { actionInfo, chargeInfo, hudInfo, tileInfo, type Info } from './ui/info' import { openOffer } from './ui/offer' +import { showShop, updateShop } from './ui/shop' import { startClip, drawClips } from './ui/clip' import { makeScene } from './ui/scenes' import { openScores, recordScore } from './ui/scores' @@ -165,8 +166,9 @@ function resumeSaved(): boolean { record = saved state = s afterChange() - // Closed with a gem's choice still open: it opens again. + // Closed with a gem's choice still open, or in the shop: it opens again. showOffer() + if (state.shop) openShopDialog() return true } catch { return false @@ -217,6 +219,18 @@ function act(action: Action, auto = false): void { inspecting = false save() afterChange() + // The shop, after every few levels: opened by going on from the level, then kept up to date. + if (action.id === 'continue' && state.shop) openShopDialog() + else if (action.id === 'buy' || action.id === 'reroll') updateShop(state) +} + +/** The shop's dialog (ui/shop.ts): buying and restocking are turns of the run record, taking no time. */ +function openShopDialog(): void { + showShop(state, { + buy: (kind) => act({ id: 'buy', kind }), + reroll: () => act({ id: 'reroll' }), + leave: () => act({ id: 'continue' }), + }) } /** diff --git a/src/render/animate.ts b/src/render/animate.ts index 874368f..8f87c09 100644 --- a/src/render/animate.ts +++ b/src/render/animate.ts @@ -50,6 +50,8 @@ export function animateTurn(a: Animation, prev: GameState, result: ReturnType<ty // waits for the move, so nothing can skip it (effects.ts `playerTransition`). const player = playerTransition(prev, next, result.events, action.id === 'teleport', now) if (player.move) a.playerMove = player.move + // Hit by a blast that shows later (an ICBM's, when it lands): the hit pose waits for it too. + if (player.pose.kind === 'hit') player.pose.at = Math.max(player.pose.at, a.fx.blastAt) if (player.move || player.pose.kind !== 'none') a.playerPose = player.pose if (player.move && player.move.from.x !== next.player.x) a.facingLeft = next.player.x < player.move.from.x const newLevel = result.events.some((e) => e.type === 'levelStarted' || e.type === 'levelRestarted') @@ -58,7 +60,8 @@ export function animateTurn(a: Animation, prev: GameState, result: ReturnType<ty a.playerPose = player.pose a.enemySteps.clear() } - for (const e of result.events) if (e.type === 'exploded') a.flames.push({ tiles: e.tiles, born: now }) + // Flame shows when the blast does (an ICBM's, when it lands). + for (const e of result.events) if (e.type === 'exploded') a.flames.push({ tiles: e.tiles, born: Math.max(now, a.fx.blastAt) }) // Every enemy that changed tile steps from where it was: from the state, like the player. if (!newLevel) { for (const enemy of next.enemies) { diff --git a/src/render/board.ts b/src/render/board.ts index a423fd3..61c20d1 100644 --- a/src/render/board.ts +++ b/src/render/board.ts @@ -105,8 +105,14 @@ const C = { iceRim: rgb(1, 1, 1, 0.8), // A bomb the Detonator can set off now: a pulsing red glow behind it. armed: rgb(1, 0.3, 0.15, 0.45), + /** An ICBM's smoke: grey, to show on the pale floor and the red of the danger overlay alike. */ + smoke: rgb(0.62, 0.63, 0.68), } +/** An ICBM's smoke trail: how many puffs, how far apart in time. */ +const SMOKE_PUFFS = 7 +const SMOKE_SPACING = 0.035 + /** The stone frame's outline: 8 of its sprites' 128 pixels. */ const STONE_OUTLINE = 8 / 128 @@ -188,8 +194,9 @@ export function drawBoard(view: View): void { for (const t of blastTiles(after!, b)) drawRect(toWorld(s, t), vec2(0.7), C.preview) } - // Flame from last turn's explosions still burning (it lingers one turn). - for (const i of s.burning) { + // Flame from last turn's explosions still burning (it lingers one turn). An ICBM still in the air: + // its blast hasn't happened yet on screen, so neither has this. + for (const i of time < fx.blastAt ? [] : s.burning) { const pos = toWorld(s, tileOf(s, i)) drawRect(pos, vec2(0.94), C.burning) drawTile(pos, vec2(0.55 + 0.05 * Math.sin(time * 9 + i)), sprite('fireball'), C.white.scale(1, 0.8), time * 4 + i) @@ -198,7 +205,7 @@ export function drawBoard(view: View): void { for (const f of view.flames) { const fade = 1 - (time - f.born) / FLAME_SECONDS - if (fade <= 0) continue + if (fade <= 0 || time < f.born) continue for (const t of f.tiles) { const pos = toWorld(s, t) drawRect(pos, vec2(0.9), C.flame.scale(1, fade)) @@ -206,6 +213,23 @@ export function drawBoard(view: View): void { } } + // An ICBM on its way (user request, 2026-10-24: not thrown in an arc, but speeding straight with + // a smoke trail): it picks up speed along the line from the player to where it lands, leaving + // puffs of smoke that swell and fade behind it. + for (const f of fx.flights) { + const along = (t: number) => { + const k = Math.min(1, Math.max(0, (t - f.at) / f.seconds)) + return toWorld(s, f.from).lerp(toWorld(s, f.to), k * k) + } + for (let i = 1; i <= SMOKE_PUFFS; i++) { + const t = time - i * SMOKE_SPACING + if (t < f.at) break + const fade = 1 - i / (SMOKE_PUFFS + 1) + drawCircle(along(t), 0.22 + 0.05 * i, C.smoke.scale(1, 0.7 * fade)) + } + drawTile(along(time), vec2(0.8), sprite('bomb')) + } + // With the Detonator, every bomb pulses: tap one and it goes off (user request, 2026-10-22). const detonatable = isAvailable(s, 'detonate') for (const b of s.bombs) { @@ -287,7 +311,7 @@ function drawPlayer(s: GameState, move: PlayerMove | null, pose: PlayerPose, fac const since = time - Math.max(pose.at, move ? move.at + moveSeconds(move) : pose.at) let pos = to let frame: BoardSprite = 'character_green_idle' - if (pose.kind === 'hit' || s.status === 'dead' || s.status === 'gameOver') { + if ((pose.kind === 'hit' || s.status === 'dead' || s.status === 'gameOver') && time >= pose.at) { frame = 'character_green_hit' pos = to.add(vec2(0.03 * Math.sin(time * 40) * Math.max(0, 1 - since * 2), 0)) } else if (pose.kind === 'jump') { diff --git a/src/render/effects.ts b/src/render/effects.ts index 65ff104..f61f985 100644 --- a/src/render/effects.ts +++ b/src/render/effects.ts @@ -23,6 +23,12 @@ export const GONE_SECONDS: Record<Gone['kind'], number> = { } /** A kicked bomb slides this long per tile; a thrown one flies for THROW_SECONDS. */ const KICK_SECONDS_PER_TILE = 0.05 +/** + * An ICBM's flight: this long, plus this much per tile (user request, 2026-10-24: it was instant; + * then, it should speed straight rather than arc like a throw — board.ts draws it). + */ +const ICBM_SECONDS = 0.25 +const ICBM_SECONDS_PER_TILE = 0.05 const THROW_SECONDS = 0.25 /** Line's bombs pop in one after another, this far apart. */ const LINE_STAGGER = 0.05 @@ -66,6 +72,13 @@ export interface Effects { gone: Gone[] /** The ice on the enemies changing: from one level to another (0 none, 1 frozen solid), starting at `at`. */ frost: { from: number; to: number; at: number } | null + /** + * ICBM bombs in the air: the sim has it go off the turn it is fired, so it isn't on the board + * any more; it is drawn flying in a high arc, and its blast shows when it lands (`blastAt`). + */ + flights: { from: Vec; to: Vec; at: number; seconds: number }[] + /** When this turn's blasts show (an ICBM's landing); until then, the board shows them not yet happened. */ + blastAt: number } export const key = { @@ -79,7 +92,7 @@ export const key = { } export function newEffects(): Effects { - return { appeared: new Map(), pulsed: new Map(), slides: new Map(), gone: [], frost: null } + return { appeared: new Map(), pulsed: new Map(), slides: new Map(), gone: [], frost: null, flights: [], blastAt: 0 } } /** A new level, or the same one again: nothing from the old board carries over. */ @@ -89,6 +102,8 @@ export function clearEffects(fx: Effects): void { fx.slides.clear() fx.gone = [] fx.frost = null + fx.flights = [] + fx.blastAt = 0 } export function dropSprite(d: Drop): BoardSprite { @@ -98,6 +113,16 @@ export function dropSprite(d: Drop): BoardSprite { /** Start the animations for one turn, from the state before it, the state after, and its events. */ export function startEffects(fx: Effects, prev: GameState, next: GameState, events: GameEvent[], now: number): void { if (events.some((e) => e.type === 'levelStarted' || e.type === 'levelRestarted')) return clearEffects(fx) + // An ICBM (fired, and gone off where it landed this same turn) flies first: everything its blast + // does — and the rest of the turn's blasts — shows when it lands, at `boom`. + const icbm = events.find( + (e): e is Extract<GameEvent, { type: 'bombFired' }> => + e.type === 'bombFired' && events.some((x) => x.type === 'exploded' && x.pos.x === e.to.x && x.pos.y === e.to.y), + ) + const flight = icbm ? ICBM_SECONDS + ICBM_SECONDS_PER_TILE * (Math.abs(icbm.to.x - icbm.from.x) + Math.abs(icbm.to.y - icbm.from.y)) : 0 + if (icbm) fx.flights.push({ from: icbm.from, to: icbm.to, at: now, seconds: flight }) + const boom = now + flight + fx.blastAt = boom // Bombs placed this turn: they pop in (or fly in) rather than swell at their first tick. const placed = new Set<string>() for (const e of events) { @@ -128,7 +153,7 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even break case 'blockDestroyed': case 'crateCrumbled': - fx.gone.push({ kind: 'crate', pos: e.pos, at: now }) + fx.gone.push({ kind: 'crate', pos: e.pos, at: boom }) break case 'enemyDied': { const enemy = prev.enemies.find((o) => o.id === e.id) @@ -136,11 +161,11 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even // 2026-10-23: it died on the new tile without ever leaving the old one). From its change // of tile, like every move. const from = enemy && (enemy.pos.x !== e.pos.x || enemy.pos.y !== e.pos.y) ? enemy.pos : undefined - if (enemy) fx.gone.push({ kind: 'enemy', enemy: { ...enemy, pos: e.pos }, from, at: from ? now + WALK_SECONDS : now }) + if (enemy) fx.gone.push({ kind: 'enemy', enemy: { ...enemy, pos: e.pos }, from, at: from ? Math.max(boom, now + WALK_SECONDS) : boom }) break } case 'dropSpawned': - fx.appeared.set(key.drop(e.drop.pos), now) + fx.appeared.set(key.drop(e.drop.pos), boom) break case 'pickedUp': fx.gone.push({ kind: 'taken', sprite: dropSprite(e.drop), pos: e.drop.pos, size: 0.62, at: now + TAKE_DELAY }) @@ -148,31 +173,31 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even 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 }) + fx.gone.push({ kind: 'burned', sprite: dropSprite(e.drop), pos: e.drop.pos, size: 0.62, at: boom }) break case 'powerUpRevealed': - fx.appeared.set(key.gem(e.powerUp.pos), now) + fx.appeared.set(key.gem(e.powerUp.pos), boom) break case 'powerUpTaken': 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: GEM_SPRITES[e.powerUp.color], 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: boom }) break case 'exitRevealed': - fx.appeared.set(key.exit, now) + fx.appeared.set(key.exit, boom) break case 'exitOpened': - fx.pulsed.set(key.exit, now) + fx.pulsed.set(key.exit, boom) break case 'decoyPlaced': fx.appeared.set(key.decoy, now) break case 'decoyGone': - fx.gone.push({ kind: 'burned', sprite: 'character_green_front', pos: e.pos, size: 0.9, at: now }) + fx.gone.push({ kind: 'burned', sprite: 'character_green_front', pos: e.pos, size: 0.9, at: boom }) break case 'shieldBroke': - fx.gone.push({ kind: 'shield', pos: e.pos, at: now }) + fx.gone.push({ kind: 'shield', pos: e.pos, at: boom }) break } } @@ -180,7 +205,7 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even if (prev.frozen !== next.frozen) fx.frost = { from: frostOf(prev), to: frostOf(next), at: now } // Enemies that weren't there before (spawned from a burned exit or power-up) pop in. for (const enemy of next.enemies) { - if (!prev.enemies.some((o) => o.id === enemy.id)) fx.appeared.set(key.enemy(enemy.id), now) + if (!prev.enemies.some((o) => o.id === enemy.id)) fx.appeared.set(key.enemy(enemy.id), boom) } // Last turn's flame that has now gone out fades. const burning = new Set(next.burning) @@ -215,6 +240,7 @@ export function pruneEffects(fx: Effects, now: number): void { for (const [k, at] of map) if (now - at > 1) map.delete(k) } for (const [k, s] of fx.slides) if (now - s.at > s.seconds) fx.slides.delete(k) + fx.flights = fx.flights.filter((f) => now - f.at < f.seconds) } /** diff --git a/src/sim/actions.ts b/src/sim/actions.ts index 0353d90..ea83783 100644 --- a/src/sim/actions.ts +++ b/src/sim/actions.ts @@ -93,6 +93,15 @@ export function teleportRange(s: GameState): number { return TELEPORT_RANGE + (s.powers.run ? 1 : 0) } +/** + * How many steps Teleport counts to a tile: across plus down (a diamond of reach); with Diagonal, + * a diagonal step counts as one, so it reaches a square (user's choice, 2026-10-24: Teleport + * already covered every diagonal step, so Diagonal widens it instead of being wasted). + */ +export function teleportSteps(s: GameState, dx: number, dy: number): number { + return s.powers.diagonal ? Math.max(Math.abs(dx), Math.abs(dy)) : Math.abs(dx) + Math.abs(dy) +} + function hasCapacity(s: GameState): boolean { return s.bombs.length < s.stats.capacity } @@ -272,7 +281,7 @@ export const ACTIONS: Record<ActionId, ActionDef> = { 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) <= range && playerCanEnter(s, p)) tiles.push(p) + if ((dx || dy) && teleportSteps(s, dx, dy) <= range && playerCanEnter(s, p)) tiles.push(p) } } return tiles diff --git a/src/sim/demo.ts b/src/sim/demo.ts index 525ad78..c55cee7 100644 --- a/src/sim/demo.ts +++ b/src/sim/demo.ts @@ -593,7 +593,7 @@ function makeRoom(kind: SceneKind, rng: Rng): GameState { cells, player: { ...player }, lives: START_LIVES, stats: { ...START_STATS }, bombs: [], status: 'playing', enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: DEMO_PAR, coins: 0, boosts: noBoosts(), shield: false, frozen: 0, decoy: null, burning: [], - powers: noPowers(), star: 0, gems: [], offer: null, death: null, runTurns: 0, runKills: 0, deaths: 0, crumbling: [], + powers: noPowers(), star: 0, gems: [], offer: null, shop: null, death: null, runTurns: 0, runKills: 0, deaths: 0, crumbling: [], aiRng: rng.int(2 ** 31), dropRng: rng.int(2 ** 31), } const open = floor().filter((p) => steps(p, player) >= 3) diff --git a/src/sim/record.ts b/src/sim/record.ts index 57d621b..edb4f71 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 = 24 +export const SIM_VERSION = 26 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */ export interface RunRecord { diff --git a/src/sim/shop.ts b/src/sim/shop.ts new file mode 100644 index 0000000..13ef245 --- /dev/null +++ b/src/sim/shop.ts @@ -0,0 +1,82 @@ +import { applyCharge } from './actions' +import { LAST_LEVEL } from './levelgen' +import { stream } from './rng' +import { applyPowerUp, heldAlready, POWER_UP_WEIGHTS } from './turn' +import { CHARGE_IDS, isCharge, type GameEvent, type GameState, type OfferKind, type Shop } from './types' + +/** + * The shop (PLAN.md §8; user's choices, 2026-10-24): it opens after every SHOP_EVERY-th cleared + * level (not the last), before the next one starts. It sells power-ups (none already held; no + * Coin bag — coins for coins) and charges, SHOP_SIZE drawn by the gem's rarity weights, priced + * by rarity; a paid restock that gets dearer each time. Its draws come from the seed, the level + * and the restock count, so a run replays exactly. Buying takes no turn. + */ +export const SHOP_EVERY = 3 +export const SHOP_SIZE = 4 + +/** Prices by rarity: common things cheap, rare ones dear (user's choice; to tune by playtesting). */ +export const PRICES: Record<OfferKind, number> = { + bombPlus: 6, firePlus: 6, fusePlus: 8, run: 10, kick: 10, throw: 10, diagonal: 10, slide: 12, rpg: 12, detonator: 12, + wallPass: 10, bombPass: 10, flamePass: 14, star: 10, heart: 18, coinBag: 0, teleport: 16, proximity: 16, + pierce: 7, power: 7, nuke: 9, shield: 8, freeze: 7, decoy: 6, icbm: 9, +} + +/** How often each charge comes up in the shop: as often as an uncommon power-up. */ +const CHARGE_WEIGHT = 7 + +/** The price of the next restock: 2 coins, one more each time in the same shop. */ +export function rerollPrice(shop: Shop): number { + return 2 + shop.rerolls +} + +/** Whether clearing `level` opens the shop. */ +export function shopAfter(level: number): boolean { + return level % SHOP_EVERY === 0 && level < LAST_LEVEL +} + +/** The shop's items: SHOP_SIZE different ones, drawn without repeats, listed power-ups first. */ +export function drawShop(s: GameState, rerolls: number): OfferKind[] { + const pool: [OfferKind, number][] = [ + ...POWER_UP_WEIGHTS.filter(([kind]) => kind !== 'coinBag' && !heldAlready(s, kind)), + ...CHARGE_IDS.map((id): [OfferKind, number] => [id, CHARGE_WEIGHT]), + ] + const order = pool.map(([kind]) => kind) + const rng = stream(s.seed, 'shop', s.level * 100 + rerolls) + const picked = new Set<OfferKind>() + while (picked.size < SHOP_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 order.filter((kind) => picked.has(kind)) +} + +export function openShop(s: GameState, events: GameEvent[]): void { + s.shop = { items: drawShop(s, 0), sold: [], rerolls: 0 } + events.push({ type: 'shopOpened' }) +} + +/** Buys `kind`: it must be on offer, not sold yet, and affordable; it works at once. */ +export function buy(s: GameState, kind: OfferKind, events: GameEvent[]): void { + const shop = s.shop + if (!shop || !shop.items.includes(kind) || shop.sold.includes(kind)) throw new Error(`${kind} is not for sale`) + if (s.coins < PRICES[kind]) throw new Error(`${kind} costs ${PRICES[kind]}, ${s.coins} coins`) + s.coins -= PRICES[kind] + shop.sold.push(kind) + if (isCharge(kind)) applyCharge(s, kind, events) + else applyPowerUp(s, kind) + events.push({ type: 'bought', kind, price: PRICES[kind] }) +} + +/** Restocks the shop for `rerollPrice` coins: a new draw (what was bought stays bought). */ +export function reroll(s: GameState, events: GameEvent[]): void { + const shop = s.shop + if (!shop) throw new Error('no shop') + const price = rerollPrice(shop) + if (s.coins < price) throw new Error(`a restock costs ${price}, ${s.coins} coins`) + s.coins -= price + shop.rerolls++ + shop.items = drawShop(s, shop.rerolls) + shop.sold = [] + events.push({ type: 'rerolled', price }) +} diff --git a/src/sim/state.ts b/src/sim/state.ts index 779776c..f96f059 100644 --- a/src/sim/state.ts +++ b/src/sim/state.ts @@ -62,7 +62,7 @@ export function createLevel(seed: number, level: number, options: RunOptions, ca 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: carry.shield, frozen: 0, decoy: null, burning: [], - powers: { ...carry.powers }, star: carry.star, gems: [powerUp], offer: null, death: null, runTurns: carry.runTurns, runKills: carry.runKills, deaths: carry.deaths, crumbling: [], + powers: { ...carry.powers }, star: carry.star, gems: [powerUp], offer: null, shop: null, death: null, runTurns: carry.runTurns, runKills: carry.runKills, deaths: carry.deaths, crumbling: [], aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level), } // Developer mode: only this (user request, 2026-10-23; it used to give every ability, stats and diff --git a/src/sim/turn.ts b/src/sim/turn.ts index 36df56a..e8fe6b6 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -4,6 +4,7 @@ import { enemyKinds, LAST_LEVEL } from './levelgen' 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 { buy, openShop, reroll, shopAfter } from './shop' 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' export interface StepResult { @@ -38,11 +39,23 @@ export function step(prev: GameState, action: Action): StepResult { // 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`) + // The shop: buying and restocking take no turn; going on leaves it for the next level. + if (action.id === 'buy' || action.id === 'reroll') { + if (s.status !== 'cleared' || !s.shop) throw new Error(`${action.id} with no shop open`) + if (action.id === 'buy') buy(s, action.kind, events) + else reroll(s, events) + return { state: s, events } + } if (action.id === 'continue') { if (s.status === 'dead') { events.push({ type: 'levelRestarted' }) return { state: createLevel(s.seed, s.level, s.options, carryOf(s)), events } } + // Every few levels, the shop first (once: going on from it starts the next level). + if (s.status === 'cleared' && !s.shop && shopAfter(s.level)) { + openShop(s, events) + return { state: s, events } + } if (s.status === 'cleared') { events.push({ type: 'levelStarted', level: s.level + 1 }) return { state: createLevel(s.seed, s.level + 1, s.options, carryOf(s)), events } @@ -307,11 +320,14 @@ const HELD_AS: Partial<Record<PowerUpKind, keyof Powers>> = { * offers the same. Developer mode's extra gem offers every one that would do anything, then * every charge, nothing drawn (user request, 2026-10-24). */ +/** An ability or pass already held: offering it would change nothing. */ +export function heldAlready(s: GameState, kind: PowerUpKind): boolean { + const held = HELD_AS[kind] + return !!held && s.powers[held] +} + export function drawOffer(s: GameState, extra = false): OfferKind[] { - const pool = POWER_UP_WEIGHTS.filter(([kind]) => { - const held = HELD_AS[kind] - return !held || !s.powers[held] - }) + const pool = POWER_UP_WEIGHTS.filter(([kind]) => !heldAlready(s, kind)) if (extra) return [...pool.map(([kind]) => kind), ...CHARGE_IDS] const rng = stream(s.seed, 'offer', s.level) const picked = new Set<PowerUpKind>() @@ -323,7 +339,7 @@ export function drawOffer(s: GameState, extra = false): OfferKind[] { return POWER_UP_WEIGHTS.map(([kind]) => kind).filter((kind) => picked.has(kind)) } -function applyPowerUp(s: GameState, kind: PowerUpKind): void { +export function applyPowerUp(s: GameState, kind: PowerUpKind): void { switch (kind) { case 'bombPlus': s.stats.capacity++; break case 'firePlus': s.stats.fire++; break diff --git a/src/sim/types.ts b/src/sim/types.ts index a4d0ffe..5022ec7 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -246,6 +246,8 @@ export interface GameState { * retry has the gem back. */ offer: OfferKind[] | null + /** The shop, open after a cleared level every few levels until the player goes on (sim/shop.ts). */ + shop: Shop | 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 @@ -267,7 +269,20 @@ 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 } +export type Action = + | { id: ActionId; target?: Vec } + | { id: 'continue' } + | { id: 'choose'; kind: OfferKind } + /** In the shop (sim/shop.ts): buy one of its items, or restock it. */ + | { id: 'buy'; kind: OfferKind } + | { id: 'reroll' } + +/** The shop between levels (sim/shop.ts, PLAN.md §8): what it has, what is sold, how often restocked. */ +export interface Shop { + items: OfferKind[] + sold: OfferKind[] + rerolls: number +} /** * How the player died, for the death and game-over banners (user request, 2026-10-24): walked @@ -314,6 +329,9 @@ export type GameEvent = | { type: 'powerUpBurned'; powerUp: PowerUp } | { type: 'powerUpTaken'; powerUp: PowerUp; offer: OfferKind[] } | { type: 'powerUpChosen'; kind: OfferKind } + | { type: 'shopOpened' } + | { type: 'bought'; kind: OfferKind; price: number } + | { type: 'rerolled'; price: number } | { 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 d68733e..c9aa076 100644 --- a/src/style.css +++ b/src/style.css @@ -767,7 +767,13 @@ button.almanac-entry.has-clip:active { color: var(--ink); } -.ui-button:active { +/* Not now (the shop's Buy without enough coins): greyed, and no press. */ +.ui-button:disabled { + opacity: 0.45; + cursor: default; +} + +.ui-button:active:not(:disabled) { transform: translateY(3px); } @@ -1397,3 +1403,75 @@ canvas.clip[hidden] { #banner .clip-frame { width: min(300px, 80vw); } + +/* The shop (ui/shop.ts): the coins on top, then the clip and the items as in the gem's offer, each + with its price on the right of its name; a sold one dimmed. */ +#dialog-body .shop-coins { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin: 0 0 8px; +} + +#dialog-body .shop-coins img, +.shop-item .price img { + width: 20px; + height: 20px; +} + +.shop-item strong { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.shop-item .price { + display: inline-flex; + align-items: center; + gap: 3px; + font-weight: 800; +} + +.shop-item.sold { + opacity: 0.5; + cursor: default; +} + +/* The shop's buttons: Restock and Next level side by side, one short row. The selected item's own + Buy button (smaller; only on the selected card), and a smaller clip, so the items have room. */ +#dialog .panel.shop #dialog-buttons { + grid-template-columns: 1fr 1fr; +} + +#dialog .panel.shop .ui-button { + min-height: 44px; + font-size: 13px; + white-space: nowrap; +} + +#dialog .panel.shop .offer-clip .clip-frame, +#dialog .panel.shop .offer-clip .offer-still { + max-width: 200px; +} + +.shop-item { + box-sizing: border-box; + cursor: pointer; +} + +.shop-item .buy { + display: none; + width: 100%; + margin-top: 6px; +} + +#dialog .panel.shop .shop-item .buy { + min-height: 38px; + font-size: 13px; +} + +.shop-item[aria-pressed='true'] .buy { + display: flex; +} diff --git a/src/ui/dialog.ts b/src/ui/dialog.ts index ce3b004..5e55e74 100644 --- a/src/ui/dialog.ts +++ b/src/ui/dialog.ts @@ -10,8 +10,10 @@ export interface DialogButton { kind: 'primary' | 'danger' | 'plain' /** An image URL shown before the label. */ icon?: string - /** Runs after the dialog closes. */ + /** Runs after the dialog closes — or at once, with `keepOpen`. */ onClick?: () => void + /** The dialog stays open (the shop's Buy and Restock). */ + keepOpen?: boolean } export interface DialogContent { @@ -23,6 +25,8 @@ export interface DialogContent { buttons: DialogButton[] /** Must be answered from its body: no close button, and neither Esc nor the backdrop closes it. */ required?: boolean + /** A class on the panel, for a dialog laid out its own way (the shop). */ + className?: string } const dialog = document.getElementById('dialog') as HTMLDialogElement @@ -98,6 +102,7 @@ export function openDialog(content: DialogContent): void { required = !!content.required closeButton.hidden = required panel.classList.toggle('required', required) + panel.className = ['panel', required ? 'required' : '', content.className ?? ''].filter(Boolean).join(' ') title.textContent = content.title body.innerHTML = content.body buttons.replaceChildren( @@ -106,7 +111,7 @@ export function openDialog(content: DialogContent): void { el.type = 'button' el.className = `ui-button ${b.kind}` el.innerHTML = (b.icon ? `<img src="${b.icon}" alt="">` : '') + `<span>${b.label}</span>` - el.addEventListener('click', () => closeDialog(b.onClick)) + el.addEventListener('click', () => (b.keepOpen ? b.onClick?.() : closeDialog(b.onClick))) return el }), ) @@ -116,6 +121,9 @@ export function openDialog(content: DialogContent): void { else dialog.showModal() // Focus the panel, not the first button, so nothing looks pre-selected; Tab still reaches the buttons. panel.focus() + // The body is one element for every dialog, so it kept the last one's scroll: each opens at the + // top (user report, 2026-10-24). After showing, as a closed dialog has no layout to scroll. + body.scrollTop = 0 } export function dialogOpen(): boolean { diff --git a/src/ui/info.ts b/src/ui/info.ts index 5d9c0bc..2053839 100644 --- a/src/ui/info.ts +++ b/src/ui/info.ts @@ -2,6 +2,7 @@ import { BOMB_BOOST, DECOY_TURNS, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE, tele 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' +import { SHOP_EVERY } from '../sim/shop' import type { ActionId, ChargeId, Death, Dir, EnemyKind, GameState, Powers, PowerUpKind, Vec } from '../sim/types' import { dirBetween } from '../sim/enemies' import type { SceneKind } from '../sim/demo' @@ -41,8 +42,8 @@ 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_RANGE + 1} with Run).` }, - diagonal: { name: 'Diagonal', text: 'Move can also step diagonally, even squeezing between two blocked tiles.' }, + 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; with Diagonal, a diagonal step counts as one).` }, + diagonal: { name: 'Diagonal', text: 'Move can also step diagonally, even squeezing between two blocked tiles. With Teleport, it reaches a square around you: a diagonal step counts as one.' }, 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.' }, @@ -160,7 +161,7 @@ export function hudInfo(item: HudItem, s: GameState): Info { return { icon: 'hud_coin', title: `Coins: ${s.coins}`, - body: ['From enemies, coin bags, and finishing a level under par. They will buy upgrades in the shop (coming soon).'], + body: [`From enemies, coin bags, and finishing a level under par. Spend them in the shop, which opens after every ${SHOP_EVERY}rd level.`], clip: 'coins', } case 'turn': @@ -342,7 +343,7 @@ export function actionInfo(s: GameState, picked: ActionId): Info { 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 ${teleportRange(s)} steps away, over anything.`) + if (s.powers.teleport) body.push(`Teleport: any empty tile up to ${teleportRange(s)} steps away${s.powers.diagonal ? ' (diagonal steps count as one)' : ''}, 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.') diff --git a/src/ui/shop.ts b/src/ui/shop.ts new file mode 100644 index 0000000..0107cef --- /dev/null +++ b/src/ui/shop.ts @@ -0,0 +1,122 @@ +import { CHARGE_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites' +import type { SceneKind } from '../sim/demo' +import { PRICES, rerollPrice } from '../sim/shop' +import { isCharge, type GameState, type OfferKind } from '../sim/types' +import { startClip, stopClip } from './clip' +import { dialogOpen, openDialog } from './dialog' +import { CHARGE_TEXT, POWER_UP_CLIP, POWER_UP_TEXT } from './info' +import coinIcon from '../../assets/kenney/hud_coin.png' + +/** + * The shop's dialog (sim/shop.ts, PLAN.md §8): the coins, a clip of the item selected (as in the + * gem's offer), the items with their prices, and Restock and Next level side by side. The + * selected item has its own Buy button, so the dialog's buttons take one short row and the items + * have room (user report, 2026-10-24: three big buttons left little, and it scrolled all the + * time). Buying and restocking keep it open (and it shows the new state); Next level leaves. It + * can't be closed any other way. + */ +export interface ShopHandlers { + buy(kind: OfferKind): void + reroll(): void + leave(): void +} + +let selected: OfferKind | null = null +/** Whether the open dialog is the shop, the state it shows, and what to do. */ +let open = false +let current: GameState | null = null +let handlers: ShopHandlers | null = null + +function textOf(k: OfferKind): { name: string; text: string } { + return isCharge(k) ? CHARGE_TEXT[k] : POWER_UP_TEXT[k] +} + +function clipOf(k: OfferKind): SceneKind | undefined { + return isCharge(k) ? k : POWER_UP_CLIP[k] +} + +export function showShop(s: GameState, h: ShopHandlers): void { + selected = null + handlers = h + openDialog({ + title: 'Shop', + body: + '<p class="shop-coins"></p>' + + '<div class="offer-clip"><canvas class="clip" aria-hidden="true"></canvas><img class="offer-still" alt="" draggable="false" hidden></div>' + + '<div class="shop-items"></div>', + buttons: [ + { label: 'Restock', kind: 'plain', keepOpen: true, onClick: h.reroll }, + { label: 'Next level', kind: 'primary', onClick: () => ((open = false), h.leave()) }, + ], + required: true, + className: 'shop', + }) + open = true + updateShop(s) +} + +/** Shows the shop as `s` has it: after a purchase or a restock. Nothing if it isn't open. */ +export function updateShop(s: GameState): void { + const shop = s.shop + if (!open || !dialogOpen() || !shop) return + current = s + document.querySelector('#dialog-body .shop-coins')!.innerHTML = + `<img src="${coinIcon}" alt="" draggable="false"> You have <b>${s.coins}</b> ${s.coins === 1 ? 'coin' : 'coins'}.` + document.querySelector('#dialog-body .shop-items')!.innerHTML = shop.items + .map((k) => { + const sold = shop.sold.includes(k) + const price = sold ? 'Sold' : `<img src="${coinIcon}" alt="" draggable="false">${PRICES[k]}` + // A card selects (it can't be a <button>: its Buy button is inside it). + const buy = sold + ? '' + : `<button type="button" class="ui-button primary buy" data-buy="${k}"${s.coins < PRICES[k] ? ' disabled' : ''}>` + + `<span>${s.coins < PRICES[k] ? `Need ${PRICES[k] - s.coins} more` : `Buy for ${PRICES[k]}`}</span></button>` + return ( + `<div class="almanac-entry offer-choice shop-item${sold ? ' sold' : ''}" data-item="${k}" role="button" tabindex="0" aria-pressed="false">` + + `<img src="${spriteUrl(isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k])}" alt="" draggable="false">` + + `<div><strong>${textOf(k).name}<span class="price">${price}</span></strong><p>${textOf(k).text}</p>${buy}</div></div>` + ) + }) + .join('') + // After a restock or a purchase, the first item still for sale, unless the selected one still is. + if (!selected || !shop.items.includes(selected) || shop.sold.includes(selected)) selected = shop.items.find((k) => !shop.sold.includes(k)) ?? null + select(s, selected) +} + +/** Selects `k` (or nothing, all sold): its card marked, showing its Buy button, the clip playing it. */ +function select(s: GameState, k: OfferKind | null): void { + selected = k + const shop = s.shop! + for (const card of document.querySelectorAll<HTMLElement>('#dialog-body .shop-item')) { + card.setAttribute('aria-pressed', String(card.dataset.item === k)) + } + const [restock] = document.querySelectorAll<HTMLButtonElement>('#dialog-buttons button') + restock!.querySelector('span')!.textContent = `Restock — ${rerollPrice(shop)}` + restock!.disabled = s.coins < rerollPrice(shop) + const canvas = document.querySelector<HTMLCanvasElement>('#dialog-body .offer-clip canvas.clip') + const still = document.querySelector<HTMLImageElement>('#dialog-body .offer-still') + if (!canvas || !still) return + const clip = k && clipOf(k) + canvas.hidden = !clip + still.hidden = !!clip || !k + if (clip) return startClip(canvas, clip) + stopClip(canvas) + if (k) still.src = spriteUrl(isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k]) +} + +// A tap on an item for sale selects it; its Buy button buys it. +const dialogBody = document.getElementById('dialog-body')! +dialogBody.addEventListener('click', (e) => { + if (!open || !current?.shop) return + const buy = (e.target as Element).closest<HTMLElement>('[data-buy]') + if (buy) return handlers?.buy(buy.dataset.buy as OfferKind) + const card = (e.target as Element).closest<HTMLElement>('[data-item]') + if (card && !card.classList.contains('sold')) select(current, card.dataset.item as OfferKind) +}) +// The cards are buttons for the keyboard too: Enter or Space selects one. +dialogBody.addEventListener('keydown', (e) => { + const card = (e.target as Element).closest<HTMLElement>('[data-item]') + if (!open || !current?.shop || !card || (e.key !== 'Enter' && e.key !== ' ') || e.target !== card) return + e.preventDefault() + if (!card.classList.contains('sold')) select(current, card.dataset.item as OfferKind) +}) diff --git a/test/helpers.ts b/test/helpers.ts index 1382991..ec6ae89 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -21,7 +21,7 @@ export function board(picture: string, stats: Partial<Stats> = {}): GameState { lives: START_LIVES, stats: { ...START_STATS, ...stats }, bombs: [], status: 'playing', enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 100, coins: 0, boosts: noBoosts(), shield: false, frozen: 0, decoy: null, burning: [], - powers: noPowers(), star: 0, gems: [], offer: null, death: null, runTurns: 0, runKills: 0, deaths: 0, crumbling: [], aiRng: 1, dropRng: 1, + powers: noPowers(), star: 0, gems: [], offer: null, shop: null, death: null, runTurns: 0, runKills: 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}`) diff --git a/test/new-powers.test.ts b/test/new-powers.test.ts index 2f09736..f176271 100644 --- a/test/new-powers.test.ts +++ b/test/new-powers.test.ts @@ -174,3 +174,21 @@ describe('Slide', () => { expect(step(s, { id: 'slide', target: v(4, 1) }).state.status).toBe('cleared') }) }) + +describe('Diagonal widens Teleport', () => { + it('with Diagonal, Teleport counts a diagonal step as one: a square of reach', () => { + const s = board(` + ######### + #.......# + #.......# + #.......# + #...P...# + #########`) + s.powers.teleport = true + // 3 across and 3 up: 6 steps across plus down, 3 counting diagonal steps as one. + expect(ACTIONS.teleport.validTargets(s)).not.toContainEqual(v(1, 1)) + s.powers.diagonal = true + expect(ACTIONS.teleport.validTargets(s)).toContainEqual(v(1, 1)) + expect(ACTIONS.teleport.validTargets(s)).toContainEqual(v(7, 1)) + }) +}) diff --git a/test/shop.test.ts b/test/shop.test.ts new file mode 100644 index 0000000..5256f94 --- /dev/null +++ b/test/shop.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { LAST_LEVEL } from '../src/sim/levelgen' +import { drawShop, PRICES, rerollPrice, SHOP_EVERY, SHOP_SIZE, shopAfter } from '../src/sim/shop' +import { newRun } from '../src/sim/state' +import { step } from '../src/sim/turn' +import type { GameState } from '../src/sim/types' + +/** A run on `level`, just cleared, with `coins`. */ +function cleared(level: number, coins = 100): GameState { + const s = newRun(5, level) + s.status = 'cleared' + s.coins = coins + return s +} + +describe('the shop', () => { + it(`opens after every ${SHOP_EVERY}rd cleared level, not after the last`, () => { + expect([1, 2, 3, 4, 5, 6].filter(shopAfter)).toEqual([3, 6]) + expect(shopAfter(LAST_LEVEL)).toBe(false) + const { state, events } = step(cleared(SHOP_EVERY), { id: 'continue' }) + expect(events).toContainEqual({ type: 'shopOpened' }) + expect(state.level).toBe(SHOP_EVERY) + expect(state.shop!.items).toHaveLength(SHOP_SIZE) + // Going on from it starts the next level, the shop gone. + const next = step(state, { id: 'continue' }).state + expect(next.level).toBe(SHOP_EVERY + 1) + expect(next.shop).toBeNull() + }) + + it('goes straight on after the other levels', () => { + const { state } = step(cleared(1), { id: 'continue' }) + expect(state.level).toBe(2) + expect(state.shop).toBeNull() + }) + + it('sells power-ups not held (no Coin bag) and charges, the same for the same seed', () => { + const s = cleared(3) + s.powers.run = true + for (let r = 0; r < 30; r++) { + const items = drawShop(s, r) + expect(new Set(items).size).toBe(SHOP_SIZE) + expect(items).not.toContain('run') + expect(items).not.toContain('coinBag') + expect(drawShop(s, r)).toEqual(items) + } + }) + + it('buying takes the coins, works at once, carries on to the next level, and sells once', () => { + let s = step(cleared(3, 40), { id: 'continue' }).state + s.shop!.items = ['run', 'shield', 'heart', 'bombPlus'] + s = step(s, { id: 'buy', kind: 'run' }).state + expect(s.coins).toBe(40 - PRICES.run) + expect(s.powers.run).toBe(true) + s = step(s, { id: 'buy', kind: 'shield' }).state + expect(s.shield).toBe(true) + expect(() => step(s, { id: 'buy', kind: 'run' })).toThrow() + const next = step(s, { id: 'continue' }).state + expect(next.powers.run).toBe(true) + expect(next.coins).toBe(40 - PRICES.run - PRICES.shield) + }) + + it('refuses what the player can’t afford', () => { + const s = step(cleared(3, 3), { id: 'continue' }).state + expect(() => step(s, { id: 'buy', kind: s.shop!.items[0]! })).toThrow() + }) + + it('restocks for 2 coins, then 3, 4…, with a new draw', () => { + let s = step(cleared(3, 20), { id: 'continue' }).state + expect(rerollPrice(s.shop!)).toBe(2) + s = step(s, { id: 'reroll' }).state + expect(s.coins).toBe(18) + expect(rerollPrice(s.shop!)).toBe(3) + s = step(s, { id: 'reroll' }).state + expect(s.coins).toBe(15) + expect(s.shop!.items).toEqual(drawShop(s, 2)) + }) + + it('takes no actions on the board while open, and none outside a shop', () => { + const s = step(cleared(3), { id: 'continue' }).state + expect(() => step(s, { id: 'wait' })).toThrow() + expect(() => step(newRun(5), { id: 'buy', kind: 'heart' })).toThrow() + }) +}) diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index 4394f59..a9f0257 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -75,6 +75,9 @@ const SHOTS = [ { 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') }, + // The shop, after level 3 (seed 37, Immortal: a way through found by searching random play with + // the sim, 2026-10-24), its first item selected and playing above. + { name: 'shop', query: 'seed=37&level=3&immortal', act: async (page) => { await press('KeyS', 'KeyB', 'KeyS', 'KeyW', 'KeyS', 'KeyW', 'KeyD', 'KeyB', 'KeyD', 'KeyA', 'KeyS', 'KeyS', 'KeyD', 'KeyA', 'KeyS', 'KeyB', 'KeyW', 'KeyS', 'KeyW', 'KeyS', 'KeyS', 'KeyS', 'KeyD', 'KeyA', 'KeyD', 'KeyD', 'KeyS')(page); await page.waitForTimeout(800); await page.click('#banner-button'); await page.waitForTimeout(1200) } }, // 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.