explodorama / commit
Touch shortcuts, all action charges, lingering flame, 3-move enemy plans
PLAN.md | 65 ++++++++--- README.md | 47 ++++++-- assets/kenney/bridge_logs.png | Bin 0 -> 2043 bytes assets/kenney/hud_player_helmet_green.png | Bin 0 -> 2994 bytes assets/kenney/ramp.png | Bin 0 -> 1069 bytes assets/kenney/spikes.png | Bin 0 -> 1469 bytes assets/kenney/torch_on_a.png | Bin 0 -> 2724 bytes assets/kenney/weight.png | Bin 0 -> 2271 bytes assets/kenney/window.png | Bin 0 -> 1843 bytes decisions.md | 66 +++++++++++- docs/screenshots/auto-move.png | Bin 0 -> 239199 bytes docs/screenshots/bomb-picked.png | Bin 238948 -> 238931 bytes docs/screenshots/charges.png | Bin 261017 -> 269004 bytes docs/screenshots/countdown.png | Bin 244041 -> 244028 bytes docs/screenshots/death.png | Bin 197380 -> 199288 bytes docs/screenshots/desktop.png | Bin 586370 -> 585942 bytes docs/screenshots/enemies.png | Bin 252615 -> 250386 bytes docs/screenshots/lingering.png | Bin 0 -> 248179 bytes docs/screenshots/long-press.png | Bin 0 -> 242461 bytes docs/screenshots/pierce-decoy.png | Bin 0 -> 277755 bytes docs/screenshots/scrolled.png | Bin 253517 -> 253258 bytes docs/screenshots/start.png | Bin 237858 -> 237840 bytes src/main.ts | 115 +++++++++++++++++--- src/render/board.ts | 126 +++++++++++++++++----- src/render/sprites.ts | 16 +++ src/sim/actions.ts | 173 +++++++++++++++++++++++++----- src/sim/danger.ts | 10 +- src/sim/enemies.ts | 108 ++++++++++++++----- src/sim/explosions.ts | 5 +- src/sim/path.ts | 30 ++++++ src/sim/record.ts | 2 +- src/sim/state.ts | 30 ++++-- src/sim/turn.ts | 37 +++++-- src/sim/types.ts | 42 ++++++-- src/style.css | 19 +++- src/ui/controls.ts | 25 +++-- test/charges.test.ts | 171 +++++++++++++++++++++++++++++ test/danger.test.ts | 3 +- test/enemies.test.ts | 114 ++++++++++++++++++-- test/helpers.ts | 8 +- test/path.test.ts | 42 ++++++++ tools/screenshots.mjs | 31 ++++++ 42 files changed, 1111 insertions(+), 174 deletions(-) diff --git a/PLAN.md b/PLAN.md index 4c7ade7..f7fae95 100644 --- a/PLAN.md +++ b/PLAN.md @@ -3,7 +3,7 @@ A turn-based, mobile-first, roguelike take on the 1985 NES *Bomberman*: 50 levels and an ending. Built with **LittleJS + TypeScript + Vite**, tested with **Vitest**, deployed to **GitLab Pages**. -Status: **M0–M4 done, M5 well under way** (see §18). This document is the design and the hand-off +Status: **M0–M5 done** (see §18); M6 next. This document is the design and the hand-off for whoever continues the implementation. Read it fully before writing code, especially §1 (the turn), §15 (notes for the implementer) and §18 (milestones). Reasons for decisions are in `decisions.md`. @@ -38,16 +38,17 @@ One turn, in this exact order: 1. **Player acts.** The player picks one action and a target; it resolves immediately (move, run, place bomb, kick, …). Picking up whatever is on the destination tile happens here. -2. **Enemies execute their announced intents.** Each enemy moves as its intent arrow showed. - An enemy whose intended tile became blocked this turn (e.g. the player put a bomb there) - stays put — it does **not** re-plan mid-turn. +2. **Enemies execute their announced intents.** Each enemy takes the first of its planned moves + (it shows the next 3, §4). An enemy whose next tile became blocked this turn (e.g. the player + put a bomb there) stays put — it does **not** re-plan mid-turn. 3. **Bombs tick.** Every bomb's timer decreases by 1. Bombs reaching **0** explode. Chain reactions resolve **within the same phase** (a bomb caught in a blast explodes now, regardless of its timer). 4. **Damage.** Flame kills the player (unless protected) and enemies, destroys soft blocks (revealing anything under them), and destroys pickups lying in the open (see §5, NES rule). + **Last turn's flame, still burning, also kills** the player and enemies in it. An enemy sharing the player's tile kills the player. -5. **Enemies plan.** Every surviving enemy computes and displays its next intent. +5. **Enemies plan.** Every surviving enemy tops its plan up to **3 moves** and displays them. Curse and Star/Shield turn counters advance. Input unlocks. Rules that fall out of this: @@ -55,8 +56,14 @@ Rules that fall out of this: - **Bomb number = how many more player actions until it explodes.** A newly placed bomb shows its fuse value (default **3**) and is **not** ticked on the turn it is placed. A bomb showing **1** explodes at the end of the player's next action. -- **Flame is instantaneous.** It exists only during phases 3–4 (and visually during the - animation). It does not persist on the board into the next turn. +- **Flame lingers one extra turn** (*changed 2026-10-20 at the user's request*; it used to be + instantaneous). A blast burns in phase 4 of its turn, then stays burning through the whole + next turn: enemies that step into it in that turn's phase 2 die in its phase 4, as does a + player who waits in it. The player can't step, teleport or put a bomb into a burning tile. + Lingering flame doesn't burn crates, drops, the exit or bombs again (so drops from a kill + survive), and it doesn't set off bombs. This is the turn-based version of the NES flames + staying on screen for a moment, which is where most early kills come from: with instant + flame, a kill needed the enemy on the exact tile at the exact turn. - **Contact is checked twice:** after phase 1 (player walked into an enemy — death) and after phase 2 (enemy walked into player — death). Player and enemy swapping tiles counts as contact. - **Bombs block movement** for the player and for (most) enemies. Flame stops at the first hard @@ -91,6 +98,22 @@ Starting values can be raised by Workshop tiers (§7). | **Place bomb** | On any **adjacent** free tile (orthogonal). Limited by bomb capacity. | Valid tiles. Hovering/pressing one shows the faded **blast cross** and the starting timer. | | **Wait** | Do nothing; the world advances one turn. | Nothing (or a subtle pulse on the player). | +**Touch shortcuts (user request, 2026-10-20; built):** + +- **Tap the player** = Wait. +- **Press and hold** (450 ms) a free tile next to the player, with no action picked = Place bomb + there. The bomb and its blast preview appear when the hold registers; release places it, + sliding off cancels. +- **Auto-move:** tap a reachable tile that isn't adjacent, with no action picked, to set it as a + destination (flag + dotted path, next step highlighted). The Move button turns into **Go**; + each press takes one step along the shortest walk (`sim/path.ts`, over free tiles, ignoring + enemies), so it is still one turn per step and fully recorded as ordinary Move actions. + **Tapping the flag also takes one step** (user request, 2026-10-20). Tapping another far tile + moves the flag. Recomputed every turn; cleared on arrival, when unreachable, on Esc, by tapping + a wall or crate, or on death / level change. Neither Go nor the flag takes a known-deadly step + (onto an enemy, onto an enemy's intent tile, or into flame this turn): the next step shows red + and Go falls back to picking Move. + ### 3.2 Abilities (permanent once gained — no charges) | Action | Source | Rule | @@ -106,15 +129,15 @@ The button shows a count, e.g. "Kick ×2". Using one spends one charge and the t |---|---| | **Kick** | Push an adjacent bomb; it slides until the next tile is not free. It keeps its timer. The escape hatch for boxing yourself in. | | **Throw** | Place a bomb **exactly 2 tiles away** in a straight line, over a wall/soft block/bomb. Target must be free. | -| **Fuse +1 / Fuse −1** | Change any visible bomb's timer by 1 (minimum 1). One action; the direction is chosen at target time. | +| **Fuse +1 / Fuse −1** | Change any visible bomb's timer by 1 (minimum 1). One action; the direction is chosen at target time. *Built:* one Fuse charge shows two buttons, Fuse +1 and Fuse −1; either spends it. Maximum 9. | | **Shield** | Absorb the next hit (flame or enemy). Shown as a ring around the player. Arming it costs the turn. *Built:* it also absorbs any further hit in the same turn it breaks (e.g. an enemy and then a flame), so one shield never fails halfway through a turn. | | **Diagonal step** | Move 1 tile diagonally. Both orthogonal neighbours may be blocked — it's a squeeze. | -| **Teleport** (rare) | Jump to any free tile within Manhattan distance 3. | +| **Teleport** (rare) | Jump to any free tile within Manhattan distance 3. *Built:* drops at half the weight of each other charge. | | **Pierce bomb** | Place a bomb whose flame passes **through** soft blocks (destroying all in range). From later Bomberman games. | -| **Power bomb** | Place a bomb with maximum fire (whole row/column up to hard walls). | +| **Power bomb** | Place a bomb with maximum fire (whole row/column up to hard walls). *Built:* soft blocks still stop it (Pierce is the one for those). | | **Line bomb** | Place all remaining bomb capacity in a straight line from the player, in one action. From *Super Bomberman*. | | **Freeze** | All enemies skip their next move. *Built:* that is the move their arrows show when Freeze is used, i.e. this same turn's phase 2, so there is never a greyed-out state to show. | -| **Decoy** | Place a decoy on an adjacent tile; chasing enemies target it instead of the player for 3 turns. Flame destroys it. | +| **Decoy** | Place a decoy on an adjacent tile; chasing enemies target it instead of the player for 3 turns. Flame destroys it. *Built:* it blocks nothing; "3 turns" = 3 enemy plannings; one decoy at a time (a new one replaces it). | All charge actions can also appear in the shop (§8). @@ -125,7 +148,15 @@ All charge actions can also appear in the shop (§8). - **Danger overlay (always on, unless cursed with Fog):** tiles that will burn **this turn** = strong red; **next turn** = light red. Includes chain reactions. Computed by a pure function in `sim/danger.ts` from the current state. -- **Enemy intent arrows** on every enemy (phase 5). A 2-tile mover shows a 2-step arrow. +- **Enemy plans, 3 moves ahead** (*changed 2026-10-20 at the user's request*; it used to be + 1). Every enemy commits to its next 3 moves and shows them as a **vertical stack of see-through + arrows down the right side of its own tile** (the enemy drawn slightly smaller and to the + left to make room): the next move at the top, strongest; the 2nd and 3rd below it, fading. Each arrow is a single polygon, so a see-through + arrow is evenly tinted (a shaft + head drawn separately showed a darker overlap). It makes + exactly those moves unless the board blocks one (then it stays put that turn and plans + afresh). Planned moves a new bomb blocks are dropped and re-planned in phase 5. Freeze keeps + plans. Reason: bombs take 3 turns, so the player needs to see 3 turns ahead to set traps. + (A 2-tile mover will show one arrow per tile.) - **Bomb timers:** large number on each bomb; pulses at 1. - **Commit preview:** after picking an action and pressing a target, show the outcome (player ghost at destination, faded blast cross, slide path for Kick) before release/confirm. @@ -396,7 +427,9 @@ Sprite assignments (✅ = drawn now; the rest are copied and waiting for their f | ✅ banners | `character_green_hit` (lost a life), `hud_heart_empty` (game over), `flag_green_a` (level cleared); favicon `bomb` | | ✅ exit (locked / open) | `door_closed` + `door_closed_top`, `door_open` + `door_open_top`, squeezed into one tile. (`lock_yellow` spare.) | | ✅ coins | `coin_bronze` (1), `coin_silver` (2), `coin_gold` (5), bobbing; `hud_coin` in the HUD | -| ✅ action charges | Kick `spring_out`, Throw `sign_right`, Shield `switch_blue`, Freeze `chain` (drops and buttons) | +| ✅ action charges | Kick `spring_out`, Throw `sign_right`, Shield `switch_blue`, Freeze `chain`, Fuse ± `torch_on_a`, Diagonal `ramp`, Teleport `window`, Pierce `spikes`, Power `weight`, Line `bridge_logs`, Decoy `hud_player_helmet_green` (drops, buttons; Pierce / Power also as a badge on their bombs) | +| ✅ decoy | `character_green_front`, faded | +| ✅ auto-move flag | `flag_green_a` | | ✅ intent arrows | drawn red lines with a head, not sprites | | pickups (power-ups) | gems: `gem_red` Fire +1, `gem_blue` Bomb +1, `gem_yellow` Fuse +1, `gem_green` Skates (suggested; final mapping when M6 is built); `heart` Heart; `star` Star | | ✅ Blob | `slime_normal_rest` (nowhere to go), `_walk_a` / `_walk_b` alternating; mirrored when heading right. `_flat` spare for a death animation. | @@ -552,9 +585,9 @@ tools/ icons.mjs (PWA icons), screenshots.mjs (Playwright) 5. **M4 – World:** ✅ levelgen (31 × 13), side-scrolling camera, exit door under a soft block (opens when all enemies are dead; flaming it spawns 3 enemies), par + coin bonus (2026-10-20). 6. **M5 – Enemies:** ✅ Blob, Seeker, Drifter with intent arrows, both contact checks, coin and - charge drops, coins, charge inventory (buttons with counts), and the first 4 charge actions: - Kick, Throw, Shield, Freeze (2026-10-20). ⏳ The other 7 charge actions (Fuse ±, Diagonal, - Teleport, Pierce, Power, Line, Decoy). + charge drops, coins, charge inventory (buttons with counts), and all 11 charge actions + (2026-10-20). Touch shortcuts from the user's playtest: tap self = Wait, hold = Bomb, + tap far = auto-move (§3.1). 7. **M6 – Run loop:** NES pickups, coins, shop after each level, Keep/Fresh death choice, curses from level 2, 50-level progression and ending, mid-run save. 8. **M7 – Look & feel:** animations, sounds (Kenney pack, §13), PWA icons, remaining diff --git a/README.md b/README.md index a5f0b93..002899c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ counts down with *your* moves, so nothing happens until you act — and you can what will burn before you commit. Status: **early development.** The board, moving, bombs, chain reactions, lives, the first three -enemy types, coins, action charges and the exit to the next level work. Power-ups, the shop, +enemy types, coins, all eleven action charges and the exit to the next level work. Power-ups, the shop, more enemies and the 50-level ending are planned — see [PLAN.md](PLAN.md).  @@ -21,8 +21,21 @@ where their arrows pointed, bombs count down and explode, and enemies show their | Action | Phone | Keyboard | What it does | |---|---|---|---| | **Move** | Tap a tile next to you | W A S D or arrow keys | Step one tile up, down, left or right onto an empty tile. | -| **Bomb** | Tap **Bomb**, then a highlighted tile | **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. | -| **Wait** | Tap **Wait** | Space | Do nothing; the world takes its turn. | +| **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. | +| **Wait** | Tap **Wait**, or tap **yourself** | Space | Do nothing; the world takes its turn. | + +**Auto-move:** tap an empty tile further away. A flag marks it, dots show the way, and the +**Move** button becomes **Go**: each press of Go — or each tap on the flag itself — takes **one +step** along the way (one turn, as always — the world still moves between steps). The way is +worked out again every turn. Tapping another far tile moves the flag there. Auto-move ends when +you arrive, when the flag can no longer be reached, when you tap a wall or crate, or on **Esc**. +It **won't take a deadly step**: onto an enemy, onto a tile an enemy's arrow points at, or into +flame this turn. The next step then shows red, and Go / the flag wait for you to deal with it +(Go falls back to picking Move, so you can step aside yourself). + +**Press and hold** a tile next to you (about half a second, with a short buzz on phones that +support it) to arm a bomb there: its blast shows, and letting go places it. Sliding off the +tile first cancels it. **Action charges** are one-use actions that enemies sometimes drop. Their buttons appear in a second row, with a badge showing how many you hold; using one spends one and takes your turn. @@ -33,6 +46,16 @@ second row, with a badge showing how many you hold; using one spends one and tak | **Throw** | arrow sign | Tap **Throw**, then a highlighted tile | **T**, then a direction | Place a bomb exactly 2 tiles away in a straight line, over anything in between. Uses your bomb limit like a normal bomb. | | **Shield** | blue dome | Tap **Shield** | **H** | A ring appears round you. The next hit (flame or enemy) is absorbed, and so is anything else that hits you in that same turn. | | **Freeze** | chain | Tap **Freeze** | **F** | Every enemy skips the step its arrow shows this turn. | +| **Fuse +1 / Fuse −1** | torch | Tap **Fuse +1** or **Fuse −1**, then any bomb | **=** or **−**, then tap a bomb | Add 1 to a bomb's number (up to 9) or take 1 off (down to 1). One Fuse charge gives both buttons; using either spends it. | +| **Diagonal** | ramp | Tap **Diagonal**, then a highlighted tile | **G**, then **Q E Z C** (up-left, up-right, down-left, down-right) | Step one tile diagonally — even squeezing between two blocked tiles. | +| **Teleport** (rare) | stone archway | Tap **Teleport**, then a highlighted tile | **P**, then tap a tile | Jump to any empty tile within 3 steps (counting across and down), over anything. | +| **Pierce** | spikes | Tap **Pierce**, then a highlighted tile | **I**, then a direction | A bomb whose flame goes **through** crates, burning every one in reach. Marked with a spikes badge. | +| **Power** | weight | Tap **Power**, then a highlighted tile | **O**, then a direction | A bomb whose flame reaches all the way to the walls (crates still stop it). Marked with a weight badge. | +| **Line** | logs | Tap **Line**, then a highlighted tile | **L**, then a direction | Places as many bombs as you're still allowed, in a row starting at that tile, until the row is blocked. | +| **Decoy** | helmet | Tap **Decoy**, then a highlighted tile | **Y**, then a direction | A pale copy of you. Enemies that chase you (Seekers) chase it instead for 3 turns. Flame destroys it; it doesn't block anyone. | + +With an action picked, holding or hovering a highlighted tile previews what it will do: where you +will end up, a faded bomb with its blast, a kicked bomb's stopping place, a changed number. - Picking an action highlights the tiles you can choose, in blue. Tapping the action again, pressing **Esc**, or tapping anywhere else cancels it. @@ -48,21 +71,29 @@ second row, with a badge showing how many you hold; using one spends one and tak - The blast is a cross reaching **1 tile** in each direction. It stops at walls; it destroys the first soft block (wooden crate) in each direction and stops there. - A bomb caught in a blast **explodes at once**, whatever its number (a chain reaction). +- **Flame lingers for one more turn.** After a blast, its tiles keep burning (orange, with + flickering fireballs) through the next turn: any enemy that walks in dies, and so do you if + you wait in it. You can't step or put a bomb into burning tiles. It goes out after that turn. - Bombs block movement, for you and for enemies. - Bombs can't be placed on an enemy. ### Danger -- **Strong red** tiles will burn at the end of this turn if you wait. -- **Light red** tiles will burn at the end of the next turn. +- **Strong red** tiles will burn at the end of this turn if you wait (including flame still + burning from last turn). +- **Light red** tiles will burn at the end of the next turn (including flame that will still be + burning then). ### Enemies <img src="docs/screenshots/enemies.png" alt="Level 13: a Drifter saw, a Blob and a Seeker fly, the moving ones with red arrows" width="260" align="right"> -Every enemy shows a **red arrow** toward the tile it will step onto next turn. No arrow (and -closed eyes): it has nowhere to go and stays put. If you put a bomb on the tile an arrow -points at, that enemy stays put this turn. Touching an enemy costs a life — walking into it, it +Every enemy shows its **next 3 moves** as a column of see-through red arrows on the right side +of its tile: the top arrow is its next move (strongest), the ones below are the 2nd and 3rd +moves (fainter). It makes exactly +those moves, one per turn, so you can time a bomb for where it will be. No arrows (and closed +eyes): it has nowhere to go and stays put. If you put a bomb on a tile it planned to walk to, +it stops there and plans a new route. Touching an enemy costs a life — walking into it, it walking into you, or the two of you swapping places. Flame kills enemies. | Enemy | Looks like | Appears from | Moves | diff --git a/assets/kenney/bridge_logs.png b/assets/kenney/bridge_logs.png new file mode 100644 index 0000000..3c23e04 Binary files /dev/null and b/assets/kenney/bridge_logs.png differ diff --git a/assets/kenney/hud_player_helmet_green.png b/assets/kenney/hud_player_helmet_green.png new file mode 100644 index 0000000..cbc9d66 Binary files /dev/null and b/assets/kenney/hud_player_helmet_green.png differ diff --git a/assets/kenney/ramp.png b/assets/kenney/ramp.png new file mode 100644 index 0000000..ab44665 Binary files /dev/null and b/assets/kenney/ramp.png differ diff --git a/assets/kenney/spikes.png b/assets/kenney/spikes.png new file mode 100644 index 0000000..5cbaad6 Binary files /dev/null and b/assets/kenney/spikes.png differ diff --git a/assets/kenney/torch_on_a.png b/assets/kenney/torch_on_a.png new file mode 100644 index 0000000..13256d9 Binary files /dev/null and b/assets/kenney/torch_on_a.png differ diff --git a/assets/kenney/weight.png b/assets/kenney/weight.png new file mode 100644 index 0000000..73cac7d Binary files /dev/null and b/assets/kenney/weight.png differ diff --git a/assets/kenney/window.png b/assets/kenney/window.png new file mode 100644 index 0000000..462cdc8 Binary files /dev/null and b/assets/kenney/window.png differ diff --git a/decisions.md b/decisions.md index 97329a6..a81d06a 100644 --- a/decisions.md +++ b/decisions.md @@ -89,6 +89,70 @@ the README credits Kenney anyway. only a punishment, never a coin farm. - **Par = 50 + 10 per enemy, bonus 5 coins.** A first guess, to tune by playtesting. - **`?charges=N` is part of the run record** (`startCharges`), since it changes the starting - state; replays stay exact. `SIM_VERSION` is now 2. + state; replays stay exact. `SIM_VERSION` is now 2 (3 after the lingering-flame change below). - **Death banner says "Ouch!"**, not "Boom.", since enemies can kill too now. +### 2026-10-20 — Touch shortcuts: tap self to wait, hold to bomb, tap far to auto-move + +Asked for by the user after playing. All three live in the UI layer (`main.ts`); the sim only +ever sees ordinary Wait, Bomb and Move actions, so the run record and replays are unchanged. + +- **Auto-move is one step per press, not a walk that plays out by itself.** The game is + turn-based and the world moves between steps, so the player keeps the chance to stop. The + path ignores enemies (they move), but Go never steps onto one. +- **Long press is 450 ms**, a common long-press length that doesn't feel slow. It only arms a + bomb on a tile next to the player with nothing picked, so it can't clash with the two-tap + targeting or with the press-and-slide preview. + +### 2026-10-20 — The other seven action charges + +- **Fuse ± is one charge with two buttons** (Fuse +1, Fuse −1) rather than one button that asks + which way afterwards: the action log stays a plain `{ id, target }`, and the choice is + visible up front. Timers go from 1 to 9. +- **Pierce flame still stops at bombs** (setting them off), like any flame; it only ignores + crates. +- **Power bomb fire = the level's width**, so it always reaches the walls; crates stop it. +- **Line places bombs one per tile from the tile picked**, until capacity or a blocked tile + ends the row. With the starting capacity of 1 it is just a bomb; it gets better with Bomb+. +- **Decoy blocks nothing** and only Seekers chase (the other types don't target the player). + It lasts 3 enemy plannings. One at a time. +- **Teleport is rare** by drop weight (½ of each other charge), as the plan asked. +- Keys: `=` / `−` Fuse, G Diagonal (then Q E Z C), P Teleport, I Pierce, O Power, L Line, + Y Decoy. + +### 2026-10-20 — Tapping the auto-move flag steps toward it + +The user asked for the flag itself to act like Go, so the thumb can stay on the board. That +took over "tap the flag again to clear it", so clearing moved to tapping a wall or crate (a tap +that otherwise does nothing), besides Esc and the existing automatic cases. + +Testing the flag tap in a browser walked the player straight into a Blob's announced step. So +auto-move now refuses any step it knows is deadly — onto an enemy, onto a tile an enemy's arrow +points at, or into flame this turn — and shows that next step in red. Manual moves are not +restricted: the player can still choose a risky step on purpose. + +### 2026-10-20 — Lingering flame, and enemies planning 3 moves ahead + +The user found kills too hard: flame lasted one turn, and an enemy had to be on the exact tile at +that moment. The cause: bombs take 3 turns, but enemies only showed 1 move, and Blobs keep +their direction only 75% of the time, so a 3-turn guess was right about 42% of the time. Agreed +fix, two parts: + +- **Flame lingers one extra turn** (option 1 of the ones offered). It is the turn-based version + of the NES flames staying on screen briefly, which is where most early kills come from there. + Lingering flame kills creatures (enemies, player, decoy) but not crates, drops, the exit or + bombs, so the drops a kill leaves survive. The player can't enter it, since walking into fire + you can see would only ever be a mistake. Changes the PLAN.md §1 rule "flame is instantaneous". +- **Enemies commit to 3 moves and show them** (instead of option 3, making Blobs steadier): the + user asked for the next 2–3 moves to be shown, stacked vertically, future ones fading. First + drawn above the enemy's head on a white backing; the user found that blocky and spilling into + the tile above, so they are now see-through arrows down the right side of the enemy's own + tile, with the enemy drawn a little smaller and to the left to make room (the user briefly + tried full-size enemies under the arrows, then chose the smaller enemy). + Each arrow is one polygon, so it has no darker band where shaft and head would overlap. The moves are a real plan, not a forecast: the enemy makes exactly those moves unless + the board blocks one, so the player can plan a 3-turn bomb against them. Moves a newly placed + bomb blocks are dropped and re-planned in phase 5; the blocked-this-turn rule for the next move + is unchanged. Layout chosen by the user over a chain of arrows along the path. +- Auto-move's safety check uses each enemy's next planned move. +- Starting fire stays at 1, so Fire+ pickups (M6) still have room to matter. + diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png new file mode 100644 index 0000000..dd0735b Binary files /dev/null and b/docs/screenshots/auto-move.png differ diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png index b4b2f2d..a760034 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 0d2d81e..4ef5d94 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 f0abe4b..8e78746 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 f009bae..d4adf48 100644 Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png index ff4b38d..ebcea15 100644 Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png index 413e83f..7eba8cf 100644 Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png new file mode 100644 index 0000000..9e1053e Binary files /dev/null and b/docs/screenshots/lingering.png differ diff --git a/docs/screenshots/long-press.png b/docs/screenshots/long-press.png new file mode 100644 index 0000000..443c5c0 Binary files /dev/null and b/docs/screenshots/long-press.png differ diff --git a/docs/screenshots/pierce-decoy.png b/docs/screenshots/pierce-decoy.png new file mode 100644 index 0000000..4ce5d41 Binary files /dev/null and b/docs/screenshots/pierce-decoy.png differ diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png index 119129c..b3585c8 100644 Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png index 0a69656..68bac0c 100644 Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ diff --git a/src/main.ts b/src/main.ts index 8c590e5..7b368ce 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,9 +14,11 @@ import { BACKGROUND, drawBoard, FLAME_SECONDS, toTile, type Flame } from './rend import { updateCamera } from './render/camera' import { TEXTURES } from './render/sprites' import { isAvailable, isValidTarget, ACTIONS } from './sim/actions' +import { enemyAt } from './sim/enemies' +import { playerPath } from './sim/path' import { predictDanger, type Danger } from './sim/danger' import { newRecord, type RunRecord } from './sim/record' -import { inBounds, newRun } from './sim/state' +import { eq, idx, inBounds, newRun } from './sim/state' import { step } from './sim/turn' import { DIRS, type Action, type ActionId, type Dir, type GameState, type Vec } from './sim/types' import { createControls } from './ui/controls' @@ -36,6 +38,14 @@ let hover: Vec | null = null let flames: Flame[] = [] let facingLeft = false let snapCamera = true +/** Auto-move: a far tile the player tapped. Each press of Move takes one step along `path` toward it. */ +let destination: Vec | null = null +let path: Vec[] | null = null +/** Long press: the adjacent tile a held finger has armed for a bomb, placed on release. */ +let armed: Vec | null = null +let pressTimer: number | undefined +let pressTile: Vec | null = null +const LONG_PRESS_MS = 450 function startRun(seed: number): void { record = newRecord(seed, startLevel, startCharges) @@ -54,6 +64,7 @@ function act(action: Action): void { for (const e of result.events) { if (e.type === 'exploded') flames.push({ tiles: e.tiles, born: time }) if (e.type === 'levelRestarted' || e.type === 'levelStarted') snapCamera = true + if (e.type === 'levelRestarted' || e.type === 'levelStarted' || e.type === 'levelCleared') destination = null if (e.type === 'moved' && e.to.x !== e.from.x) facingLeft = e.to.x < e.from.x } selected = null @@ -62,10 +73,20 @@ function act(action: Action): void { function afterChange(): void { danger = predictDanger(state) - controls.update(state, selected) + // The walk is worked out again every turn, since bombs and crates change; reaching the + // destination, or it becoming unreachable, ends auto-move. + if (destination && (eq(state.player, destination) || state.status !== 'playing')) destination = null + path = destination ? playerPath(state, destination) : null + if (!path) destination = null + controls.update(state, selected, destination !== null) } -/** Two taps: pick an action, then a highlighted tile. With nothing picked, a tap next to the player moves. */ +/** + * Two taps: pick an action, then a highlighted tile. With nothing picked: a tap next to the + * player moves there, a tap on the player waits, a tap on a reachable tile further away sets it + * as the auto-move destination, a tap on that flag takes one step toward it (like Go), and a tap + * on a wall or crate clears it. + */ function tapTile(p: Vec): void { if (state.status !== 'playing') return if (selected) { @@ -74,11 +95,39 @@ function tapTile(p: Vec): void { selected = null afterChange() } - } else if (isValidTarget(state, 'move', p)) act({ id: 'move', target: p }) + } else if (eq(p, state.player)) pick('wait') + // The flag first: even when it is next to the player, tapping it is an auto-move step, with + // auto-move's safety check, not a plain move. + else if (destination && eq(destination, p)) stepTowardDestination() + else if (isValidTarget(state, 'move', p)) act({ id: 'move', target: p }) + else if (playerPath(state, p)) { + destination = p + afterChange() + } else if (destination) { + destination = null + afterChange() + } +} + +/** + * Whether auto-move may take this step: not onto an enemy, not onto the tile an enemy moves to + * next, and not into flame this turn. Those would be deaths the player didn't choose. + */ +function safeStep(p: Vec): boolean { + return !enemyAt(state, p) && !state.enemies.some((e) => e.plan[0] && eq(e.plan[0], p)) && !danger.now.has(idx(state, p)) +} + +/** One step of auto-move, if the next step is safe. True if it stepped. */ +function stepTowardDestination(): boolean { + const next = path?.[0] + if (!next || !safeStep(next) || !isValidTarget(state, 'move', next)) return false + act({ id: 'move', target: next }) + return true } function pick(id: ActionId): void { if (!isAvailable(state, id)) return + if (id === 'move' && destination && !selected && stepTowardDestination()) return if (!ACTIONS[id].needsTarget) act({ id }) else { selected = selected === id ? null : id @@ -113,20 +162,43 @@ const KEY_DIRS: Record<string, Dir> = { KeyD: 'right', ArrowRight: 'right', } -const ACTION_KEYS: Record<string, ActionId> = { KeyB: 'bomb', KeyK: 'kick', KeyT: 'throw', KeyH: 'shield', KeyF: 'freeze' } +const ACTION_KEYS: Record<string, ActionId> = { + KeyB: 'bomb', KeyK: 'kick', KeyT: 'throw', KeyH: 'shield', KeyF: 'freeze', Equal: 'fuseUp', Minus: 'fuseDown', + KeyG: 'diagonal', KeyP: 'teleport', KeyI: 'pierce', KeyO: 'power', KeyL: 'line', KeyY: 'decoy', +} + +/** With Diagonal picked: Q / E / Z / C step up-left, up-right, down-left, down-right. */ +const DIAGONAL_KEYS: Record<string, Vec> = { KeyQ: { x: -1, y: -1 }, KeyE: { x: 1, y: -1 }, KeyZ: { x: -1, y: 1 }, KeyC: { x: 1, y: 1 } } window.addEventListener('keydown', (e) => { if (e.repeat) return const d = KEY_DIRS[e.code] - if (d) direction(d) + const diag = selected === 'diagonal' ? DIAGONAL_KEYS[e.code] : undefined + if (diag) { + const p = { x: state.player.x + diag.x, y: state.player.y + diag.y } + if (isValidTarget(state, 'diagonal', p)) act({ id: 'diagonal', target: p }) + } else if (d) direction(d) else if (e.code === 'Space') pick('wait') else if (ACTION_KEYS[e.code]) pick(ACTION_KEYS[e.code]!) else if (e.code === 'Escape') { selected = null + destination = null afterChange() } else if (e.code === 'Enter') controls.pressBanner() }) +/** Start timing a long press on `p`, or with null, cancel any press in progress. */ +function startPress(p: Vec | null): void { + clearTimeout(pressTimer) + armed = null + pressTile = p + if (!p || selected || state.status !== 'playing' || !isValidTarget(state, 'bomb', p)) return + pressTimer = window.setTimeout(() => { + armed = p + navigator.vibrate?.(15) + }, LONG_PRESS_MS) +} + function tileUnder(e: PointerEvent): Vec | null { const r = mainCanvas.getBoundingClientRect() const p = toTile(state, screenToWorld(vec2(e.clientX - r.left, e.clientY - r.top))) @@ -149,17 +221,32 @@ await engineInit( setShowSplashScreen(false) setCanvasClearColor(BACKGROUND) // Pressing shows the preview on the tile under the finger; releasing commits to it. - // Sliding to another tile before letting go changes the choice. - mainCanvas.addEventListener('pointerdown', (e) => (hover = tileUnder(e))) + // Sliding to another tile before letting go changes the choice. Holding still on a tile + // next to the player, with nothing picked, arms a bomb there instead (placed on release). + mainCanvas.addEventListener('pointerdown', (e) => { + hover = tileUnder(e) + startPress(hover) + }) mainCanvas.addEventListener('pointermove', (e) => { - if (e.pointerType === 'mouse' || e.buttons) hover = tileUnder(e) + if (e.pointerType !== 'mouse' && !e.buttons) return + const p = tileUnder(e) + if (e.buttons && !(p && pressTile && eq(p, pressTile))) startPress(null) + hover = p }) mainCanvas.addEventListener('pointerup', (e) => { const p = tileUnder(e) if (e.pointerType !== 'mouse') hover = null - if (p) tapTile(p) + const bombAt = armed + startPress(null) + if (bombAt && p && eq(p, bombAt)) act({ id: 'bomb', target: bombAt }) + else if (p) tapTile(p) + }) + mainCanvas.addEventListener('pointerleave', () => { + hover = null + startPress(null) }) - mainCanvas.addEventListener('pointerleave', () => (hover = null)) + // A long press shouldn't open the browser's context menu. + mainCanvas.addEventListener('contextmenu', (e) => e.preventDefault()) }, () => { updateCamera(state, controls.insets(), snapCamera) @@ -167,7 +254,11 @@ await engineInit( flames = flames.filter((f) => time - f.born < FLAME_SECONDS) }, () => {}, - () => drawBoard({ state, danger, selected, hover, flames, facingLeft }), + () => + drawBoard({ + state, danger, selected, hover, flames, facingLeft, destination, path, armed, + pathBlocked: !!path?.[0] && !safeStep(path[0]), + }), () => {}, TEXTURES, ) diff --git a/src/render/board.ts b/src/render/board.ts index b7f2d9e..d51af79 100644 --- a/src/render/board.ts +++ b/src/render/board.ts @@ -1,9 +1,10 @@ -import { drawCircle, drawLine, drawPoly, drawRect, drawTile, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine' -import { ACTIONS, kickDestination } from '../sim/actions' +import { drawCircle, drawPoly, drawRect, drawTile, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine' +import { ACTIONS } from '../sim/actions' import type { Danger } from '../sim/danger' import { blastTiles } from '../sim/explosions' -import { cellAt, eq, exitOpen, idx } from '../sim/state' -import type { ActionId, Enemy, GameState, Vec } from '../sim/types' +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 { CHARGE_ICONS, coinSprite, digitSprite, ENEMY_SPRITES, sprite, type BoardSprite } from './sprites' /** @@ -33,6 +34,13 @@ export interface View { flames: Flame[] /** The way the player last stepped. The character sprite faces right; this mirrors it. */ facingLeft: boolean + /** Auto-move: the tapped far tile, and the walk there (next step first). */ + destination: Vec | null + path: Vec[] | null + /** The next auto-move step is unsafe, so Go and the flag won't take it this turn. */ + pathBlocked: boolean + /** A tile a long press has armed for a bomb. */ + armed: Vec | null } export const FLAME_SECONDS = 0.45 @@ -49,9 +57,12 @@ const C = { flame: rgb(1, 0.6, 0.1), white: rgb(1, 1, 1), ghost: rgb(1, 1, 1, 0.45), - intent: rgb(0.85, 0.2, 0.2, 0.85), + intent: rgb(0.85, 0.2, 0.2), shield: rgb(0.25, 0.62, 0.87, 0.9), clear: rgb(0, 0, 0, 0), + pathDot: rgb(0.25, 0.62, 0.87, 0.7), + burning: rgb(1, 0.55, 0.1, 0.5), + blocked: rgb(0.85, 0.2, 0.2, 0.35), } export const BACKGROUND: Color = C.sky @@ -89,8 +100,29 @@ export function drawBoard(view: View): void { drawRect(toWorld(s, t), vec2(0.94), hovered && eq(t, hovered) ? C.targetHover : C.target) } } - if (hovered && selected === 'bomb') { - for (const t of blastTiles(s, { pos: hovered, fire: s.stats.fire })) drawRect(toWorld(s, t), vec2(0.7), C.preview) + if (view.destination && view.path) { + for (const [i, t] of view.path.entries()) { + if (i === 0) drawRect(toWorld(s, t), vec2(0.94), view.pathBlocked ? C.blocked : C.target) + else drawCircle(toWorld(s, t), 0.16, C.pathDot) + } + drawTile(toWorld(s, view.destination), vec2(0.8), sprite('flag_green_a')) + } + // The commit preview: the picked action played on a copy, then whatever it changed drawn faded. + const after = view.armed + ? previewOf(s, 'bomb', view.armed) + : selected && hovered + ? previewOf(s, selected, hovered) + : null + const changedBombs = after ? after.bombs.filter((b) => !s.bombs.some((o) => eq(o.pos, b.pos) && o.timer === b.timer)) : [] + for (const b of changedBombs) { + 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) { + 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) } for (const f of view.flames) { @@ -106,13 +138,11 @@ export function drawBoard(view: View): void { for (const b of s.bombs) { // The last turn before it goes off, it flashes. const hot = b.timer <= 1 && Math.sin(time * 14) > 0 - drawBomb(toWorld(s, b.pos), b.timer, hot ? 'bomb_active' : 'bomb', C.white) - } - if (hovered && selected === 'kick') { - const bomb = s.bombs.find((b) => eq(b.pos, hovered)) - if (bomb) drawBomb(toWorld(s, kickDestination(s, hovered)), bomb.timer, 'bomb', C.ghost) + drawBomb(toWorld(s, b.pos), b.timer, hot ? 'bomb_active' : 'bomb', C.white, b.special) } - if (hovered && (selected === 'bomb' || selected === 'throw')) drawBomb(toWorld(s, hovered), s.stats.fuse, 'bomb', C.ghost) + for (const b of changedBombs) drawBomb(toWorld(s, b.pos), b.timer, 'bomb', C.ghost, b.special) + if (s.decoy) drawDecoy(toWorld(s, s.decoy.pos), C.white) + if (after?.decoy && !s.decoy) drawDecoy(toWorld(s, after.decoy.pos), C.ghost) // Drops bob gently, so they read as things to pick up rather than part of the floor. for (const [i, d] of s.drops.entries()) { @@ -123,7 +153,9 @@ export function drawBoard(view: View): void { for (const e of s.enemies) drawEnemy(s, e) if (s.shield) drawCircle(toWorld(s, s.player), 1.08, C.clear, 0.07, C.shield) - if (hovered && selected === 'move') drawTile(toWorld(s, hovered), tileSize, sprite('character_green_idle'), C.ghost, 0, view.facingLeft) + if (after && !eq(after.player, s.player)) { + drawTile(toWorld(s, after.player), tileSize, sprite('character_green_idle'), C.ghost, 0, view.facingLeft) + } const hit = s.status === 'dead' || s.status === 'gameOver' drawTile(toWorld(s, s.player), tileSize, sprite(hit ? 'character_green_hit' : 'character_green_idle'), C.white, 0, view.facingLeft) } @@ -135,29 +167,69 @@ function drawExit(pos: Vector2, open: boolean): void { drawTile(pos.add(vec2(0, 0.34)), half, sprite(open ? 'door_open_top' : 'door_closed_top')) } -/** An enemy, and its intent: an arrow toward the tile it will step onto next turn. */ +/** How strongly each planned move's arrow shows: the next one solid, later ones fading. */ +const PLAN_ALPHA = [0.85, 0.55, 0.3] + +/** + * An enemy, and its plan: a see-through column of arrows down the right side of its own tile, + * one per planned move, the next move at the top (the user's choice of layout, decisions.md). + * The enemy is drawn a little smaller and to the left to make room. + */ function drawEnemy(s: GameState, e: Enemy): void { const pos = toWorld(s, e.pos) const art = ENEMY_SPRITES[e.kind] - const frame = !e.intent ? art.rest : Math.floor(time * 2.5 + e.id) % 2 ? art.a : art.b - const angle = art.spins && e.intent ? time * 3 : 0 - const mirror = art.facesLeft && e.dir === 'right' - drawTile(pos, vec2(0.9), sprite(frame), C.white, angle, mirror) - if (!e.intent) return - const d = vec2(e.intent.x - e.pos.x, e.pos.y - e.intent.y) // world y is up - const tip = pos.add(d.scale(0.82)) - drawLine(pos.add(d.scale(0.5)), tip, 0.08, C.intent) - const side = vec2(-d.y, d.x).scale(0.13) - drawPoly([tip.add(d.scale(0.12)), tip.add(side), tip.subtract(side)], C.intent) + const moving = e.plan.length > 0 + const frame = !moving ? art.rest : Math.floor(time * 2.5 + e.id) % 2 ? art.a : art.b + const angle = art.spins && moving ? time * 3 : 0 + const heading = moving ? dirBetween(e.pos, e.plan[0]!) : e.dir + const mirror = art.facesLeft && heading === 'right' + drawTile(pos.add(vec2(-0.09, 0)), vec2(0.8), sprite(frame), C.white, angle, mirror) + if (!moving) return + + const gap = 0.29 + const top = pos.add(vec2(0.35, gap)) + let from = e.pos + for (const [i, to] of e.plan.entries()) { + const d = vec2(to.x - from.x, from.y - to.y) // world y is up + drawArrow(top.add(vec2(0, -gap * i)), d, 0.26, C.intent.scale(1, PLAN_ALPHA[i] ?? 0.3)) + from = to + } } -/** The bomb sprite with its timer on the body, which sits a little below and right of the tile's centre. */ -function drawBomb(pos: Vector2, timer: number, body: BoardSprite, color: Color): void { +/** + * A small arrow centred on `pos`, pointing along the unit vector `d`. One polygon (shaft and + * head together), so a see-through arrow has no darker band where two shapes would overlap. + */ +function drawArrow(pos: Vector2, d: Vector2, size: number, color: Color): void { + const n = vec2(-d.y, d.x) + // Along the arrow (a) and across it (b), in units of `size`. + const at = (a: number, b: number) => pos.add(d.scale(a * size)).add(n.scale(b * size)) + drawPoly([at(0.5, 0), at(0.02, 0.32), at(0.02, 0.15), at(-0.45, 0.15), at(-0.45, -0.15), at(0.02, -0.15), at(0.02, -0.32)], color) +} + +/** The picked action played on a copy of the state, for the preview. Nothing else happens. */ +function previewOf(s: GameState, id: ActionId, target: Vec): GameState { + const copy = cloneState(s) + ACTIONS[id].apply(copy, target, []) + return copy +} + +/** The decoy: a pale copy of the player, facing out. */ +function drawDecoy(pos: Vector2, color: Color): void { + drawTile(pos, vec2(0.9), sprite('character_green_front'), color.scale(1, 0.75)) +} + +/** + * The bomb sprite with its timer on the body, which sits a little below and right of the + * tile's centre. A Pierce or Power bomb wears its charge's icon as a badge at the top left. + */ +function drawBomb(pos: Vector2, timer: number, body: BoardSprite, color: Color, special?: Bomb['special']): void { drawTile(pos, vec2(0.9), sprite(body), color) const digits = String(timer) const w = 0.32 const start = pos.add(vec2(0.04 - ((digits.length - 1) * w) / 2, -0.06)) for (let i = 0; i < digits.length; i++) drawTile(start.add(vec2(i * w, 0)), vec2(0.42), digitSprite(Number(digits[i])), color) + if (special) drawTile(pos.add(vec2(-0.28, 0.28)), vec2(0.4), sprite(CHARGE_ICONS[special]), color) } function tileOf(s: GameState, i: number): Vec { diff --git a/src/render/sprites.ts b/src/render/sprites.ts index d0a97ac..5c474e8 100644 --- a/src/render/sprites.ts +++ b/src/render/sprites.ts @@ -37,6 +37,7 @@ const BOARD = [ 'hud_character_7', 'hud_character_8', 'hud_character_9', + 'flag_green_a', 'door_closed', 'door_closed_top', 'door_open', @@ -48,6 +49,14 @@ const BOARD = [ 'sign_right', 'switch_blue', 'chain', + 'torch_on_a', + 'ramp', + 'window', + 'spikes', + 'weight', + 'bridge_logs', + 'hud_player_helmet_green', + 'character_green_front', 'slime_normal_rest', 'slime_normal_walk_a', 'slime_normal_walk_b', @@ -83,6 +92,13 @@ export const CHARGE_ICONS: Record<ChargeId, BoardSprite> = { throw: 'sign_right', shield: 'switch_blue', freeze: 'chain', + fuse: 'torch_on_a', + diagonal: 'ramp', + teleport: 'window', + pierce: 'spikes', + power: 'weight', + line: 'bridge_logs', + decoy: 'hud_player_helmet_green', } /** diff --git a/src/sim/actions.ts b/src/sim/actions.ts index 015ab0b..9dfad12 100644 --- a/src/sim/actions.ts +++ b/src/sim/actions.ts @@ -1,6 +1,6 @@ import { enemyAt } from './enemies' -import { add, bombAt, eq, isFree } from './state' -import { CHARGE_IDS, DIRS, DIR_LIST, type ActionId, type ChargeId, type GameEvent, type GameState, type Vec } from './types' +import { add, bombAt, eq, isBurning, isFree, playerCanEnter } from './state' +import { DIRS, DIR_LIST, type ActionId, type Bomb, type ChargeId, type GameEvent, type GameState, type Vec } from './types' /** * The board actions (PLAN.md §3). Adding one = an entry here, its tests, and a button. @@ -11,8 +11,8 @@ export interface ActionDef { label: string /** False for actions like Wait that happen as soon as they are picked. */ needsTarget: boolean - /** A consumable action charge (PLAN.md §3.3): only offered while the player holds one, and spends one. */ - charge?: boolean + /** The action charge it spends (PLAN.md §3.3); only offered while the player holds one. */ + charge?: ChargeId validTargets(s: GameState): Vec[] apply(s: GameState, target: Vec | undefined, events: GameEvent[]): void } @@ -21,16 +21,42 @@ function neighbours(p: Vec): Vec[] { return DIR_LIST.map((d) => add(p, DIRS[d])) } -/** Where a bomb can go: free, and no enemy standing there. */ +/** Where a bomb can go: free, not on fire, and no enemy standing there. */ function canHoldBomb(s: GameState, p: Vec): boolean { - return isFree(s, p) && !enemyAt(s, p) + return isFree(s, p) && !isBurning(s, p) && !enemyAt(s, p) } -function placeBomb(s: GameState, pos: Vec, events: GameEvent[]): void { - s.bombs.push({ pos: { ...pos }, timer: s.stats.fuse, fire: s.stats.fire, placedTurn: s.turn }) +function placeBomb(s: GameState, pos: Vec, events: GameEvent[], special?: Bomb['special']): void { + // A Power bomb's flame reaches as far as the level is wide: in practice, to the walls. + const fire = special === 'power' ? Math.max(s.width, s.height) : s.stats.fire + const bomb: Bomb = { pos: { ...pos }, timer: s.stats.fuse, fire, placedTurn: s.turn } + if (special) bomb.special = special + s.bombs.push(bomb) events.push({ type: 'bombPlaced', pos: { ...pos }, timer: s.stats.fuse }) } +function hasCapacity(s: GameState): boolean { + return s.bombs.length < s.stats.capacity +} + +/** The adjacent tiles a bomb can go on, if the player may place one. */ +function bombTargets(s: GameState): Vec[] { + return hasCapacity(s) ? neighbours(s.player).filter((p) => canHoldBomb(s, p)) : [] +} + +function walkTo(s: GameState, target: Vec, events: GameEvent[]): void { + const from = s.player + s.player = { ...target } + events.push({ type: 'moved', from, to: s.player }) +} + +/** The furthest a Fuse +1 takes a timer: one digit. */ +const MAX_TIMER = 9 +/** How far Teleport reaches (Manhattan distance). */ +export const TELEPORT_RANGE = 3 +/** How many enemy plannings a Decoy lasts. */ +export const DECOY_TURNS = 3 + /** Where a bomb kicked by the player slides to: on, away from them, until the next tile can't hold it. */ export function kickDestination(s: GameState, from: Vec): Vec { const d = { x: from.x - s.player.x, y: from.y - s.player.y } @@ -44,18 +70,14 @@ export const ACTIONS: Record<ActionId, ActionDef> = { id: 'move', label: 'Move', needsTarget: true, - validTargets: (s) => neighbours(s.player).filter((p) => isFree(s, p)), - apply(s, target, events) { - const from = s.player - s.player = { ...target! } - events.push({ type: 'moved', from, to: s.player }) - }, + validTargets: (s) => neighbours(s.player).filter((p) => playerCanEnter(s, p)), + apply: (s, target, events) => walkTo(s, target!, events), }, bomb: { id: 'bomb', label: 'Bomb', needsTarget: true, - validTargets: (s) => (s.bombs.length >= s.stats.capacity ? [] : neighbours(s.player).filter((p) => canHoldBomb(s, p))), + validTargets: bombTargets, apply: (s, target, events) => placeBomb(s, target!, events), }, wait: { @@ -71,7 +93,7 @@ export const ACTIONS: Record<ActionId, ActionDef> = { id: 'kick', label: 'Kick', needsTarget: true, - charge: true, + charge: 'kick', // An adjacent bomb with room to slide at least one tile away from the player. validTargets: (s) => DIR_LIST.map((d) => DIRS[d]).flatMap((d) => { @@ -89,10 +111,10 @@ export const ACTIONS: Record<ActionId, ActionDef> = { id: 'throw', label: 'Throw', needsTarget: true, - charge: true, + charge: 'throw', // Exactly 2 tiles away in a straight line, over anything in between. validTargets: (s) => - s.bombs.length >= s.stats.capacity + !hasCapacity(s) ? [] : DIR_LIST.map((d) => add(s.player, { x: 2 * DIRS[d].x, y: 2 * DIRS[d].y })).filter((p) => canHoldBomb(s, p)), apply: (s, target, events) => placeBomb(s, target!, events), @@ -101,7 +123,7 @@ export const ACTIONS: Record<ActionId, ActionDef> = { id: 'shield', label: 'Shield', needsTarget: false, - charge: true, + charge: 'shield', validTargets: () => [], apply(s) { s.shield = true @@ -111,18 +133,118 @@ export const ACTIONS: Record<ActionId, ActionDef> = { id: 'freeze', label: 'Freeze', needsTarget: false, - charge: true, + charge: 'freeze', validTargets: () => [], apply(s) { s.frozen = true }, }, + fuseUp: { + id: 'fuseUp', + label: 'Fuse +1', + needsTarget: true, + charge: 'fuse', + validTargets: (s) => s.bombs.filter((b) => b.timer < MAX_TIMER).map((b) => b.pos), + apply: (s, target, events) => changeFuse(s, target!, 1, events), + }, + fuseDown: { + id: 'fuseDown', + label: 'Fuse −1', + needsTarget: true, + charge: 'fuse', + validTargets: (s) => s.bombs.filter((b) => b.timer > 1).map((b) => b.pos), + apply: (s, target, events) => changeFuse(s, target!, -1, events), + }, + diagonal: { + id: 'diagonal', + label: 'Diagonal', + needsTarget: true, + charge: 'diagonal', + // A squeeze: the two orthogonal tiles in between may both be blocked. + validTargets: (s) => + [ + { x: -1, y: -1 }, + { x: 1, y: -1 }, + { x: -1, y: 1 }, + { x: 1, y: 1 }, + ] + .map((d) => add(s.player, d)) + .filter((p) => playerCanEnter(s, p)), + apply: (s, target, events) => walkTo(s, target!, events), + }, + teleport: { + id: 'teleport', + label: 'Teleport', + needsTarget: true, + charge: 'teleport', + validTargets(s) { + const tiles: Vec[] = [] + for (let dy = -TELEPORT_RANGE; dy <= TELEPORT_RANGE; dy++) { + for (let dx = -TELEPORT_RANGE; dx <= TELEPORT_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) + } + } + return tiles + }, + apply: (s, target, events) => walkTo(s, target!, events), + }, + pierce: { + id: 'pierce', + label: 'Pierce', + needsTarget: true, + charge: 'pierce', + validTargets: bombTargets, + apply: (s, target, events) => placeBomb(s, target!, events, 'pierce'), + }, + power: { + id: 'power', + label: 'Power', + needsTarget: true, + charge: 'power', + validTargets: bombTargets, + apply: (s, target, events) => placeBomb(s, target!, events, 'power'), + }, + line: { + id: 'line', + label: 'Line', + needsTarget: true, + charge: 'line', + // The tile picked sets the direction: bombs go there and on beyond it, one per free tile, + // until capacity runs out or the line is blocked. + validTargets: bombTargets, + apply(s, target, events) { + const d = { x: target!.x - s.player.x, y: target!.y - s.player.y } + for (let p = target!; hasCapacity(s) && canHoldBomb(s, p); p = add(p, d)) placeBomb(s, p, events) + }, + }, + decoy: { + id: 'decoy', + label: 'Decoy', + needsTarget: true, + charge: 'decoy', + validTargets: (s) => neighbours(s.player).filter((p) => canHoldBomb(s, p)), + apply(s, target, events) { + s.decoy = { pos: { ...target! }, turns: DECOY_TURNS } + events.push({ type: 'decoyPlaced', pos: s.decoy.pos }) + }, + }, +} + +function changeFuse(s: GameState, at: Vec, delta: number, events: GameEvent[]): void { + const bomb = bombAt(s, at)! + bomb.timer += delta + events.push({ type: 'fuseChanged', pos: bomb.pos, timer: bomb.timer }) } -export const ACTION_ORDER: readonly ActionId[] = ['move', 'bomb', 'wait', ...CHARGE_IDS] +export const ACTION_ORDER: readonly ActionId[] = [ + 'move', 'bomb', 'wait', 'kick', 'throw', 'shield', 'freeze', 'fuseUp', 'fuseDown', 'diagonal', 'teleport', + 'pierce', 'power', 'line', 'decoy', +] -export function isCharge(id: ActionId): id is ChargeId { - return ACTIONS[id].charge === true +/** The action charge an action spends, if it is a charge action. */ +export function chargeOf(id: ActionId): ChargeId | undefined { + return ACTIONS[id].charge } export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean { @@ -132,8 +254,9 @@ export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean /** Whether the action can be picked right now. */ export function isAvailable(s: GameState, id: ActionId): boolean { if (s.status !== 'playing') return false - if (isCharge(id)) { - if (s.charges[id] <= 0) return false + const charge = chargeOf(id) + if (charge) { + if (s.charges[charge] <= 0) return false if (id === 'shield' && s.shield) return false if (id === 'freeze' && s.enemies.length === 0) return false } diff --git a/src/sim/danger.ts b/src/sim/danger.ts index 6f03643..4ccf13d 100644 --- a/src/sim/danger.ts +++ b/src/sim/danger.ts @@ -10,15 +10,17 @@ export interface Danger { /** * The danger overlay (PLAN.md §4), assuming the player waits. It runs the same countdown and - * chain code as `step()`, so it can't disagree with what actually happens. + * chain code as `step()`, so it can't disagree with what actually happens. Flame lingers one + * extra turn, so "now" includes what is still burning from last turn, and "next" includes what + * explodes now. */ export function predictDanger(state: GameState): Danger { const s = cloneState(state) const ignored: GameEvent[] = [] tickBombs(s, ignored) - const now = detonate(s, ignored) + const exploding = detonate(s, ignored) s.turn++ tickBombs(s, ignored) - const next = detonate(s, ignored) - return { now, next } + const later = detonate(s, ignored) + return { now: new Set([...exploding, ...state.burning]), next: new Set([...later, ...exploding]) } } diff --git a/src/sim/enemies.ts b/src/sim/enemies.ts index e0154a2..715aa0b 100644 --- a/src/sim/enemies.ts +++ b/src/sim/enemies.ts @@ -1,6 +1,6 @@ import { Rng } from './rng' import { add, bombAt, cellAt, eq } from './state' -import { CHARGE_IDS, DIRS, DIR_LIST, type Dir, type Drop, type Enemy, type EnemyKind, type GameEvent, type GameState, type Vec } from './types' +import { CHARGE_IDS, DIRS, DIR_LIST, type ChargeId, type Dir, type Drop, type Enemy, type EnemyKind, type GameEvent, type GameState, type Vec } from './types' /** Enemies walk on floor and are stopped by bombs. They don't block each other; they can share a tile. */ export function enemyCanEnter(s: GameState, p: Vec): boolean { @@ -16,31 +16,49 @@ interface Step { to: Vec } -function openSteps(s: GameState, e: Enemy): Step[] { - return DIR_LIST.map((dir) => ({ dir, to: add(e.pos, DIRS[dir]) })).filter((st) => enemyCanEnter(s, st.to)) +/** Where an enemy is, or will be, while its moves are planned ahead. */ +interface Walker { + pos: Vec + dir: Dir +} + +/** The direction of a one-tile step. */ +export function dirBetween(from: Vec, to: Vec): Dir { + return DIR_LIST.find((d) => eq(add(from, DIRS[d]), to))! +} + +/** How many moves ahead every enemy plans, and shows (PLAN.md §4). */ +export const PLAN_AHEAD = 3 + +function openSteps(s: GameState, w: Walker): Step[] { + return DIR_LIST.map((dir) => ({ dir, to: add(w.pos, DIRS[dir]) })).filter((st) => enemyCanEnter(s, st.to)) } /** Blob: carries on the way it was going three times in four, otherwise any open way. */ -function wander(s: GameState, e: Enemy, rng: Rng): Step | null { - const steps = openSteps(s, e) - const ahead = steps.find((st) => st.dir === e.dir) +function wander(s: GameState, w: Walker, rng: Rng): Step | null { + const steps = openSteps(s, w) + const ahead = steps.find((st) => st.dir === w.dir) if (ahead && rng.next() < 0.75) return ahead return steps.length ? steps[rng.int(steps.length)]! : null } /** Drifter: straight on until blocked, then any open way. */ -function drift(s: GameState, e: Enemy, rng: Rng): Step | null { - const steps = openSteps(s, e) - return steps.find((st) => st.dir === e.dir) ?? (steps.length ? steps[rng.int(steps.length)]! : null) +function drift(s: GameState, w: Walker, rng: Rng): Step | null { + const steps = openSteps(s, w) + return steps.find((st) => st.dir === w.dir) ?? (steps.length ? steps[rng.int(steps.length)]! : null) } -/** The first step of a shortest path to the player over tiles enemies can enter, if there is one. */ -export function stepToward(s: GameState, e: Enemy): Step | null { +/** + * The first step of a shortest path over tiles enemies can enter toward what they chase: the + * decoy while there is one, otherwise the player. Null if there is no way. + */ +export function stepToward(s: GameState, w: Walker): Step | null { + const goal = s.decoy?.pos ?? s.player const key = (p: Vec) => p.y * s.width + p.x const first = new Map<number, Step>() const queue: Vec[] = [] - for (const st of openSteps(s, e)) { - if (eq(st.to, s.player)) return st + for (const st of openSteps(s, w)) { + if (eq(st.to, goal)) return st first.set(key(st.to), st) queue.push(st.to) } @@ -48,36 +66,58 @@ export function stepToward(s: GameState, e: Enemy): Step | null { const p = queue[i]! for (const d of DIR_LIST) { const q = add(p, DIRS[d]) - if (first.has(key(q)) || eq(q, e.pos) || !enemyCanEnter(s, q)) continue + if (first.has(key(q)) || eq(q, w.pos) || !enemyCanEnter(s, q)) continue first.set(key(q), first.get(key(p))!) - if (eq(q, s.player)) return first.get(key(q))! + if (eq(q, goal)) return first.get(key(q))! queue.push(q) } } return null } -const PLANNERS: Record<EnemyKind, (s: GameState, e: Enemy, rng: Rng) => Step | null> = { +const PLANNERS: Record<EnemyKind, (s: GameState, w: Walker, rng: Rng) => Step | null> = { blob: wander, // Seeker: half the time a step along the shortest path to the player, otherwise a Blob's wander. - seeker: (s, e, rng) => (rng.next() < 0.5 ? stepToward(s, e) : null) ?? wander(s, e, rng), + seeker: (s, w, rng) => (rng.next() < 0.5 ? stepToward(s, w) : null) ?? wander(s, w, rng), drifter: drift, } -/** Phase 5: every enemy announces where it will step next turn. */ +/** + * Phase 5: every enemy tops its plan up to PLAN_AHEAD moves, and those are the moves it will + * make. Planned steps the board no longer allows (a bomb now in the way) are dropped first, + * along with everything after them, and planned again from there. The steps are worked out on + * the board as it is now, so a later bomb can still block one. + */ export function planIntents(s: GameState): void { const rng = new Rng(s.aiRng) for (const e of s.enemies) { - const st = PLANNERS[e.kind](s, e, rng) - e.intent = st ? st.to : null - if (st) e.dir = st.dir + let from = e.pos + const kept: Vec[] = [] + for (const p of e.plan) { + if (!enemyCanEnter(s, p) || Math.abs(p.x - from.x) + Math.abs(p.y - from.y) !== 1) break + kept.push(p) + from = p + } + // Carry on from the last kept step, heading the way that step went. + const last = kept.at(-1) + const w: Walker = { pos: from, dir: last ? dirBetween(kept.at(-2) ?? e.pos, last) : e.dir } + e.plan = kept + while (e.plan.length < PLAN_AHEAD) { + const st = PLANNERS[e.kind](s, w, rng) + if (!st) break + e.plan.push(st.to) + w.pos = st.to + w.dir = st.dir + } + e.dir = w.dir } s.aiRng = rng.state } /** - * Phase 2: every enemy steps where it announced. One whose tile was blocked this turn (a bomb - * placed there) stays put and does not re-plan. Frozen, they all stay put once. + * Phase 2: every enemy takes the first move of its plan. One whose tile was blocked this turn + * (a bomb placed there) stays put and does not re-plan until phase 5, which plans it afresh. + * Frozen, they all stay put once and keep their plans. */ export function moveEnemies(s: GameState, events: GameEvent[]): void { if (s.frozen) { @@ -85,9 +125,15 @@ export function moveEnemies(s: GameState, events: GameEvent[]): void { return } for (const e of s.enemies) { - if (!e.intent || !enemyCanEnter(s, e.intent)) continue - events.push({ type: 'enemyMoved', id: e.id, from: e.pos, to: e.intent }) - e.pos = e.intent + const next = e.plan[0] + if (!next) continue + if (!enemyCanEnter(s, next)) { + e.plan = [] + continue + } + events.push({ type: 'enemyMoved', id: e.id, from: e.pos, to: next }) + e.pos = next + e.plan.shift() } } @@ -98,6 +144,14 @@ const DROP_CHANCE: Record<EnemyKind, { coins: number; charge: number }> = { drifter: { coins: 0.2, charge: 0.5 }, } +/** Teleport is rare (PLAN.md §3.3): half as likely as each other charge. */ +function pickCharge(rng: Rng): ChargeId { + const weight = (id: ChargeId) => (id === 'teleport' ? 0.5 : 1) + let r = rng.next() * CHARGE_IDS.reduce((sum, id) => sum + weight(id), 0) + for (const id of CHARGE_IDS) if ((r -= weight(id)) < 0) return id + return CHARGE_IDS[CHARGE_IDS.length - 1]! +} + /** What a slain enemy leaves on its tile. Coins are worth 1 (60%), 2 (30%) or 5 (10%). */ export function rollDrops(s: GameState, e: Enemy): Drop[] { if (e.spawned) return [] @@ -109,7 +163,7 @@ export function rollDrops(s: GameState, e: Enemy): Drop[] { drops.push({ pos: e.pos, kind: 'coins', amount: r < 0.6 ? 1 : r < 0.9 ? 2 : 5 }) } if (rng.next() < chance.charge) { - drops.push({ pos: e.pos, kind: CHARGE_IDS[rng.int(CHARGE_IDS.length)]! }) + drops.push({ pos: e.pos, kind: pickCharge(rng) }) } s.dropRng = rng.state return drops diff --git a/src/sim/explosions.ts b/src/sim/explosions.ts index 641c5b7..0f44f06 100644 --- a/src/sim/explosions.ts +++ b/src/sim/explosions.ts @@ -4,8 +4,9 @@ import { DIRS, DIR_LIST, type Bomb, type GameEvent, type GameState, type Vec } f /** * The tiles a bomb's flame reaches: its own tile, then up to `fire` tiles in each direction. * Flame stops before a hard wall, and stops *on* a soft block or another bomb (burning it). + * A Pierce bomb's flame carries on through soft blocks. */ -export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire'>): Vec[] { +export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'special'>): Vec[] { const tiles = [bomb.pos] for (const d of DIR_LIST) { let p = bomb.pos @@ -14,7 +15,7 @@ export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire'>): Vec[ const cell = cellAt(s, p) if (cell === 'wall') break tiles.push(p) - if (cell === 'block' || bombAt(s, p)) break + if ((cell === 'block' && bomb.special !== 'pierce') || bombAt(s, p)) break } } return tiles diff --git a/src/sim/path.ts b/src/sim/path.ts new file mode 100644 index 0000000..ad3f456 --- /dev/null +++ b/src/sim/path.ts @@ -0,0 +1,30 @@ +import { add, eq, isFree } from './state' +import { DIRS, DIR_LIST, type GameState, type Vec } from './types' + +/** + * The shortest walk for the player to `to` over free tiles (floor, no bomb), for auto-move. The + * result leaves out the player's own tile and ends at `to`; null if `to` can't be reached. + * Enemies don't block the path, because they will have moved by the time the player gets there; + * the UI refuses a step onto an enemy instead. + */ +export function playerPath(s: GameState, to: Vec): Vec[] | null { + if (eq(s.player, to) || !isFree(s, to)) return null + const key = (p: Vec) => p.y * s.width + p.x + const prev = new Map<number, Vec>([[key(s.player), s.player]]) + const queue = [s.player] + for (let i = 0; i < queue.length; i++) { + const p = queue[i]! + for (const d of DIR_LIST) { + const q = add(p, DIRS[d]) + if (prev.has(key(q)) || !isFree(s, q)) continue + prev.set(key(q), p) + if (eq(q, to)) { + const path = [q] + for (let r = p; !eq(r, s.player); r = prev.get(key(r))!) path.unshift(r) + return path + } + queue.push(q) + } + } + return null +} diff --git a/src/sim/record.ts b/src/sim/record.ts index 4205197..dc582a5 100644 --- a/src/sim/record.ts +++ b/src/sim/record.ts @@ -6,7 +6,7 @@ import type { Action, GameState } from './types' * 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 = 2 +export const SIM_VERSION = 3 /** 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 fe03ce8..87b7d7e 100644 --- a/src/sim/state.ts +++ b/src/sim/state.ts @@ -1,7 +1,7 @@ import { planIntents } from './enemies' import { generateLevel } from './levelgen' import { hashSeed } from './rng' -import type { Bomb, Cell, ChargeId, GameState, Stats, Vec } from './types' +import { CHARGE_IDS, type Bomb, type Cell, type ChargeId, type GameState, type Stats, type Vec } from './types' export const START_LIVES = 3 export const START_STATS: Stats = { capacity: 1, fire: 1, fuse: 3 } @@ -17,14 +17,18 @@ export interface Carry { charges: Record<ChargeId, number> } +/** `n` of every action charge. */ +export function charges(n: number): Record<ChargeId, number> { + return Object.fromEntries(CHARGE_IDS.map((id) => [id, n])) as Record<ChargeId, number> +} + export function noCharges(): Record<ChargeId, number> { - return { kick: 0, throw: 0, shield: 0, freeze: 0 } + return charges(0) } -/** `charges`: start with this many of every action charge (a testing aid, `?charges=` in the URL). */ -export function newRun(seed: number, level = 1, charges = 0): GameState { - const all = { kick: charges, throw: charges, shield: charges, freeze: charges } - return createLevel(seed, level, { lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charges: all }) +/** `n`: start with this many of every action charge (a testing aid, `?charges=` in the URL). */ +export function newRun(seed: number, level = 1, n = 0): GameState { + return createLevel(seed, level, { lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charges: charges(n) }) } /** @@ -37,9 +41,9 @@ export function createLevel(seed: number, level: number, carry: Carry): GameStat seed, level, turn: 0, width, height, cells, player: start, lives: carry.lives, stats: { ...carry.stats }, coins: carry.coins, charges: { ...carry.charges }, bombs: [], status: 'playing', - enemies: enemies.map((e, id) => ({ id, kind: e.kind, pos: e.pos, dir: e.dir, intent: null, spawned: false })), + enemies: enemies.map((e, id) => ({ id, kind: e.kind, pos: e.pos, dir: e.dir, plan: [], spawned: false })), nextEnemyId: enemies.length, - drops: [], exit, par, shield: false, frozen: false, + drops: [], exit, par, shield: false, frozen: false, decoy: null, burning: [], aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level), } planIntents(s) @@ -88,3 +92,13 @@ export function bombAt(s: GameState, p: Vec): Bomb | undefined { export function isFree(s: GameState, p: Vec): boolean { return cellAt(s, p) === 'floor' && !bombAt(s, p) } + +/** Still on fire from last turn's explosion (see `GameState.burning`). */ +export function isBurning(s: GameState, p: Vec): boolean { + return inBounds(s, p) && s.burning.includes(idx(s, p)) +} + +/** Where the player can step or land: free and not on fire. */ +export function playerCanEnter(s: GameState, p: Vec): boolean { + return isFree(s, p) && !isBurning(s, p) +} diff --git a/src/sim/turn.ts b/src/sim/turn.ts index 454fa75..de845cc 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -1,4 +1,4 @@ -import { ACTIONS, isAvailable, isCharge, isValidTarget } from './actions' +import { ACTIONS, chargeOf, isAvailable, isValidTarget } from './actions' import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies' import { enemyKinds } from './levelgen' import { detonate, tickBombs } from './explosions' @@ -39,9 +39,10 @@ export function step(prev: GameState, action: Action): StepResult { if (def.needsTarget && (!action.target || !isValidTarget(s, action.id, action.target))) { throw new Error(`invalid target for ${action.id}: ${JSON.stringify(action.target)}`) } - if (isCharge(action.id)) { - s.charges[action.id]-- - events.push({ type: 'chargeUsed', id: action.id }) + const charge = chargeOf(action.id) + if (charge) { + s.charges[charge]-- + events.push({ type: 'chargeUsed', id: charge }) } def.apply(s, action.target, events) pickUp(s, events) @@ -78,20 +79,23 @@ export function step(prev: GameState, action: Action): StepResult { if (enemyAt(s, s.player)) hurt() } - // Phase 3: bombs tick; those at 0 explode, in chains. + // Phase 3: bombs tick; those at 0 explode, in chains. Last turn's flame is still burning too. + const lingering = new Set(s.burning) const exitWasOpen = cellAt(s, s.exit) === 'floor' const enemiesBefore = s.enemies.length tickBombs(s, events) const burned = detonate(s, events) - // Phase 4: damage. Flame burns the player, enemies, and drops lying in the open; slain enemies - // then leave their own drops, so those survive the flame that made them. - if (s.status === 'playing' && burned.has(idx(s, s.player))) hurt() + // Phase 4: damage. New flame and lingering flame both burn creatures: the player, enemies and + // the decoy. Only new flame burns drops lying in the open; slain enemies then leave their own + // drops, so those survive the flame that made them (and its lingering). + const fire = new Set([...burned, ...lingering]) + if (s.status === 'playing' && fire.has(idx(s, s.player))) hurt() for (const d of s.drops.filter((d) => burned.has(idx(s, d.pos)))) { s.drops.splice(s.drops.indexOf(d), 1) events.push({ type: 'dropBurned', drop: d }) } - for (const e of s.enemies.filter((e) => burned.has(idx(s, e.pos)))) { + for (const e of s.enemies.filter((e) => fire.has(idx(s, e.pos)))) { s.enemies.splice(s.enemies.indexOf(e), 1) events.push({ type: 'enemyDied', id: e.id, pos: e.pos }) for (const drop of rollDrops(s, e)) { @@ -99,19 +103,30 @@ export function step(prev: GameState, action: Action): StepResult { events.push({ type: 'dropSpawned', drop }) } } + if (s.decoy && fire.has(idx(s, s.decoy.pos))) { + events.push({ type: 'decoyGone', pos: s.decoy.pos, burned: true }) + s.decoy = null + } if (!exitWasOpen && cellAt(s, s.exit) === 'floor') events.push({ type: 'exitRevealed', pos: s.exit }) // Flaming an uncovered exit lets out a group of the level's toughest type (NES rule, PLAN.md §5). if (exitWasOpen && burned.has(idx(s, s.exit))) { const kinds = enemyKinds(s.level) for (let n = 0; n < EXIT_SPAWN; n++) { - s.enemies.push({ id: s.nextEnemyId++, kind: kinds[kinds.length - 1]!, pos: { ...s.exit }, dir: 'up', intent: null, spawned: true }) + s.enemies.push({ id: s.nextEnemyId++, kind: kinds[kinds.length - 1]!, pos: { ...s.exit }, dir: 'up', plan: [], spawned: true }) } events.push({ type: 'enemiesSpawned', pos: s.exit, count: EXIT_SPAWN }) } if (enemiesBefore > 0 && s.enemies.length === 0) events.push({ type: 'exitOpened', pos: s.exit }) - // Phase 5: enemies announce their next intents. + // This turn's flame lingers through the next turn; last turn's goes out. + s.burning = [...burned] + + // Phase 5: enemies announce their next intents. A decoy lasts a fixed number of these. planIntents(s) + if (s.decoy && --s.decoy.turns <= 0) { + events.push({ type: 'decoyGone', pos: s.decoy.pos, burned: false }) + s.decoy = null + } s.turn++ return { state: s, events } diff --git a/src/sim/types.ts b/src/sim/types.ts index 8ab9c96..c6012b1 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -31,6 +31,8 @@ export interface Bomb { fire: number /** The turn it was placed on. A bomb is not ticked on the turn it is placed. */ placedTurn: number + /** Pierce: its flame goes through soft blocks, burning them all. Power: fire to the walls. */ + special?: 'pierce' | 'power' } export interface Stats { @@ -55,18 +57,34 @@ export interface Enemy { id: number kind: EnemyKind pos: Vec - /** The way it last moved or meant to move. Blobs and Drifters keep going this way. */ + /** The way its last planned move goes. Blobs and Drifters keep going this way. */ dir: Dir - /** Where it will step next turn (phase 2), announced in phase 5. Null: it stays put. */ - intent: Vec | null + /** + * Its next moves, up to PLAN_AHEAD tiles, first one first (announced in phase 5, taken one + * per turn in phase 2). Empty: it has nowhere to go and stays put. + */ + plan: Vec[] /** Spawned as a punishment (the exit was flamed): drops nothing. */ spawned: boolean } -/** The consumable actions dropped by enemies (PLAN.md §3.3). The first four of the plan's list. */ -export type ChargeId = 'kick' | 'throw' | 'shield' | 'freeze' +/** The consumable action charges dropped by enemies (PLAN.md §3.3). */ +export type ChargeId = + | 'kick' | 'throw' | 'shield' | 'freeze' | 'fuse' | 'diagonal' | 'teleport' | 'pierce' | 'power' | 'line' | 'decoy' -export const CHARGE_IDS: readonly ChargeId[] = ['kick', 'throw', 'shield', 'freeze'] +export const CHARGE_IDS: readonly ChargeId[] = [ + 'kick', 'throw', 'shield', 'freeze', 'fuse', 'diagonal', 'teleport', 'pierce', 'power', 'line', 'decoy', +] + +/** The actions charges give: one each, except a Fuse charge, which is spent by Fuse +1 or Fuse −1. */ +export type ChargeActionId = Exclude<ChargeId, 'fuse'> | 'fuseUp' | 'fuseDown' + +/** A stand-in for the player that chasing enemies go after instead, for a few turns. */ +export interface Decoy { + pos: Vec + /** Enemy plannings left before it disappears. */ + turns: number +} /** Something lying on a floor tile to walk over and collect: coins, or one action charge. */ export type Drop = { pos: Vec; kind: 'coins'; amount: number } | { pos: Vec; kind: ChargeId } @@ -98,13 +116,20 @@ export interface GameState { shield: boolean /** Enemies skip their next move (Freeze). */ frozen: boolean + /** + * 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 + * a bomb there. It doesn't burn crates, drops or bombs a second time. + */ + burning: number[] + decoy: Decoy | null /** The AI and drop random streams' positions (`Rng.state`), so a turn is a pure function of the state. */ aiRng: number dropRng: number } /** The actions the player can pick on the board. */ -export type ActionId = 'move' | 'bomb' | 'wait' | ChargeId +export type ActionId = 'move' | 'bomb' | 'wait' | ChargeActionId export type Action = { id: ActionId; target?: Vec } | { id: 'continue' } @@ -131,3 +156,6 @@ export type GameEvent = | { type: 'chargeUsed'; id: ChargeId } | { type: 'bombKicked'; from: Vec; to: Vec } | { type: 'shieldBroke'; pos: Vec } + | { type: 'fuseChanged'; pos: Vec; timer: number } + | { type: 'decoyPlaced'; pos: Vec } + | { type: 'decoyGone'; pos: Vec; burned: boolean } diff --git a/src/style.css b/src/style.css index 3d8d85e..98005ad 100644 --- a/src/style.css +++ b/src/style.css @@ -148,7 +148,17 @@ img { font-size: 13px; } +/* Icon above the name, so names like "Teleport" fit four to a row on a phone. */ +#actions button.charge { + grid-template-columns: auto; + justify-items: center; + row-gap: 0; + padding: 2px 2px 4px; + white-space: nowrap; +} + #actions button.charge .icon { + grid-row: auto; width: 26px; height: 26px; } @@ -181,9 +191,6 @@ img { display: none; } - #actions button.charge .icon { - grid-row: auto; - } } #actions button:active:not(:disabled) { @@ -191,6 +198,12 @@ img { box-shadow: none; } +/* Auto-move: Move has become "Go", one step toward the flag per press. */ +#actions button.auto { + border-color: var(--accent-dark); + box-shadow: 0 4px 0 var(--accent-dark); +} + #actions button[aria-pressed='true'] { background: var(--accent); color: #fff; diff --git a/src/ui/controls.ts b/src/ui/controls.ts index 8f0595b..a040d03 100644 --- a/src/ui/controls.ts +++ b/src/ui/controls.ts @@ -1,4 +1,4 @@ -import { ACTIONS, ACTION_ORDER, isAvailable, isCharge } from '../sim/actions' +import { ACTIONS, ACTION_ORDER, chargeOf, isAvailable } from '../sim/actions' import { PAR_BONUS, START_LIVES } from '../sim/state' import type { ActionId, GameState } from '../sim/types' import { CHARGE_ICONS, spriteUrl } from '../render/sprites' @@ -12,10 +12,12 @@ export interface Handlers { } export const KEYS: Record<ActionId, string> = { - move: 'WASD', bomb: 'B', wait: 'Space', kick: 'K', throw: 'T', shield: 'H', freeze: 'F', + move: 'WASD', bomb: 'B', wait: 'Space', kick: 'K', throw: 'T', shield: 'H', freeze: 'F', fuseUp: '=', fuseDown: '−', + diagonal: 'G', teleport: 'P', pierce: 'I', power: 'O', line: 'L', decoy: 'Y', } const ICONS: Record<ActionId, string> = { move: 'character_green_walk_a', bomb: 'bomb', wait: 'character_green_duck', ...CHARGE_ICONS, + fuseUp: CHARGE_ICONS.fuse, fuseDown: CHARGE_ICONS.fuse, } function icon(name: string, className = 'icon'): string { @@ -34,9 +36,9 @@ export function createControls(handlers: Handlers) { const b = document.createElement('button') b.type = 'button' b.dataset.action = id - if (isCharge(id)) b.classList.add('charge') + if (chargeOf(id)) b.classList.add('charge') b.innerHTML = `${icon(ICONS[id])}<span class="label">${ACTIONS[id].label}</span><kbd>${KEYS[id]}</kbd>` - if (isCharge(id)) b.insertAdjacentHTML('beforeend', '<span class="count"></span>') + if (chargeOf(id)) b.insertAdjacentHTML('beforeend', '<span class="count"></span>') b.addEventListener('click', () => handlers.pick(id)) bar.append(b) buttons.set(id, b) @@ -58,14 +60,21 @@ export function createControls(handlers: Handlers) { } return { - update(s: GameState, selected: ActionId | null): void { + /** `autoMove`: a destination is set, so Move takes one step toward it. */ + update(s: GameState, selected: ActionId | null, autoMove: boolean): void { for (const [id, b] of buttons) { b.disabled = !isAvailable(s, id) b.setAttribute('aria-pressed', String(selected === id)) + if (id === 'move') { + b.querySelector('.label')!.textContent = autoMove ? 'Go' : ACTIONS.move.label + b.querySelector('.icon')!.setAttribute('src', spriteUrl(autoMove ? 'flag_green_a' : ICONS.move)) + b.classList.toggle('auto', autoMove) + } // Charges only get a button while the player holds some; a badge shows how many. - if (isCharge(id)) { - b.hidden = s.charges[id] === 0 - b.querySelector('.count')!.textContent = `×${s.charges[id]}` + const charge = chargeOf(id) + if (charge) { + b.hidden = s.charges[charge] === 0 + b.querySelector('.count')!.textContent = `×${s.charges[charge]}` } } // Lives lost from the starting three show as empty hearts; extra lives add full ones. diff --git a/test/charges.test.ts b/test/charges.test.ts new file mode 100644 index 0000000..25489e7 --- /dev/null +++ b/test/charges.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest' +import { ACTIONS, DECOY_TURNS, isAvailable, TELEPORT_RANGE } from '../src/sim/actions' +import { stepToward } from '../src/sim/enemies' +import { blastTiles } from '../src/sim/explosions' +import { step } from '../src/sim/turn' +import { board, v } from './helpers' + +describe('Fuse +1 / Fuse −1', () => { + it('share one Fuse charge and change any bomb by one', () => { + const s = board(` + ####### + #P..3.# + #######`) + s.charges.fuse = 1 + expect(isAvailable(s, 'fuseUp') && isAvailable(s, 'fuseDown')).toBe(true) + const up = step(s, { id: 'fuseUp', target: v(4, 1) }) + // 3 + 1, then the turn's tick takes 1 off again. + expect(up.events).toContainEqual({ type: 'fuseChanged', pos: v(4, 1), timer: 4 }) + expect(up.state.bombs[0]!.timer).toBe(3) + expect(up.state.charges.fuse).toBe(0) + expect(isAvailable(up.state, 'fuseDown')).toBe(false) + }) + + it('−1 cannot take a bomb below 1', () => { + const s = board(` + ###### + #P.1.# + ######`) + s.charges.fuse = 1 + expect(ACTIONS.fuseDown.validTargets(s)).toEqual([]) + expect(ACTIONS.fuseUp.validTargets(s)).toEqual([v(3, 1)]) + }) +}) + +describe('Diagonal', () => { + it('squeezes between two blocked tiles', () => { + const s = board(` + ##### + #P+.# + #+..# + #####`) + s.charges.diagonal = 1 + expect(ACTIONS.diagonal.validTargets(s)).toEqual([v(2, 2)]) + expect(step(s, { id: 'diagonal', target: v(2, 2) }).state.player).toEqual(v(2, 2)) + }) +}) + +describe('Teleport', () => { + it(`reaches any free tile within ${TELEPORT_RANGE} steps as the crow walks, over anything`, () => { + const s = board(` + ######### + #P#+...$# + #########`) + s.charges.teleport = 1 + expect(ACTIONS.teleport.validTargets(s)).toEqual([v(4, 1)]) + const { state } = step(s, { id: 'teleport', target: v(4, 1) }) + expect(state.player).toEqual(v(4, 1)) + }) + + it('picks up what is on the tile it lands on', () => { + const s = board(` + ###### + #P#$.# + ######`) + s.charges.teleport = 1 + expect(step(s, { id: 'teleport', target: v(3, 1) }).state.coins).toBe(1) + }) +}) + +describe('Pierce and Power bombs', () => { + it('Pierce: the flame goes through soft blocks and burns them all', () => { + const s = board(` + ######## + #P.++..# + ########`, { fire: 3 }) + const pierce = { pos: v(2, 1), fire: 3, special: 'pierce' as const } + expect(blastTiles(s, pierce)).toEqual([v(2, 1), v(1, 1), v(3, 1), v(4, 1), v(5, 1)]) + expect(blastTiles(s, { pos: v(2, 1), fire: 3 })).toEqual([v(2, 1), v(1, 1), v(3, 1)]) + }) + + it('Pierce: placed like a bomb, marked, and burns every block in reach', () => { + let s = board(` + ######## + #P.++..# + ########`, { fire: 3 }) + s.charges.pierce = 1 + s = step(s, { id: 'pierce', target: v(2, 1) }).state + expect(s.bombs[0]!.special).toBe('pierce') + s = step(s, { id: 'wait' }).state + s = step(s, { id: 'wait' }).state + const r = step(s, { id: 'wait' }) + expect(r.events.filter((e) => e.type === 'blockDestroyed')).toHaveLength(2) + }) + + it('Power: the flame reaches all the way to the walls', () => { + const s = board(` + ########## + #P.......# + ##########`) + s.charges.power = 1 + const { state } = step(s, { id: 'power', target: v(2, 1) }) + expect(state.bombs[0]!.special).toBe('power') + expect(blastTiles(state, state.bombs[0]!)).toHaveLength(8) + }) +}) + +describe('Line', () => { + it('places all remaining capacity in a row, starting next to the player', () => { + const s = board(` + ######## + #P.....# + ########`, { capacity: 3 }) + s.charges.line = 1 + const { state } = step(s, { id: 'line', target: v(2, 1) }) + expect(state.bombs.map((b) => b.pos)).toEqual([v(2, 1), v(3, 1), v(4, 1)]) + }) + + it('stops where the row is blocked', () => { + const s = board(` + ###### + #P..+# + ######`, { capacity: 5 }) + s.charges.line = 1 + expect(step(s, { id: 'line', target: v(2, 1) }).state.bombs).toHaveLength(2) + }) +}) + +describe('Decoy', () => { + it('is placed next to the player and starts counting down', () => { + const s = board(` + ####### + #P....# + #######`) + s.charges.decoy = 1 + const { state, events } = step(s, { id: 'decoy', target: v(2, 1) }) + expect(events).toContainEqual({ type: 'decoyPlaced', pos: v(2, 1) }) + expect(state.decoy).toEqual({ pos: v(2, 1), turns: DECOY_TURNS - 1 }) + }) + + it('chasing enemies head for it instead of the player', () => { + const s = board(` + ####### + #P.S..# + #######`) + expect(stepToward(s, s.enemies[0]!)?.to).toEqual(v(2, 1)) + s.decoy = { pos: v(5, 1), turns: 2 } + expect(stepToward(s, s.enemies[0]!)?.to).toEqual(v(4, 1)) + }) + + it('disappears after its turns, or when flame reaches it', () => { + let s = board(` + ####### + #P....# + #######`) + s.charges.decoy = 1 + s = step(s, { id: 'decoy', target: v(2, 1) }).state + s = step(s, { id: 'wait' }).state + const last = step(s, { id: 'wait' }) + expect(last.state.decoy).toBeNull() + expect(last.events).toContainEqual({ type: 'decoyGone', pos: v(2, 1), burned: false }) + + const burning = board(` + ###### + #P.1.# + ######`) + burning.decoy = { pos: v(2, 1), turns: 3 } + const r = step(burning, { id: 'wait' }) + expect(r.state.decoy).toBeNull() + expect(r.events).toContainEqual({ type: 'decoyGone', pos: v(2, 1), burned: true }) + }) +}) diff --git a/test/danger.test.ts b/test/danger.test.ts index ed6ece8..6bb4eec 100644 --- a/test/danger.test.ts +++ b/test/danger.test.ts @@ -16,8 +16,9 @@ function randomAction(s: GameState, rng: Rng): Action { return ACTIONS[id].needsTarget ? { id, target: targets[rng.int(targets.length)]! } : { id } } +/** What burns this turn if the player waits: new explosions, plus last turn's flame still burning. */ function burnedBy(s: GameState): Set<number> { - const tiles = new Set<number>() + const tiles = new Set<number>(s.burning) for (const e of step(s, { id: 'wait' }).events) if (e.type === 'exploded') e.tiles.forEach((t) => tiles.add(idx(s, t))) return tiles } diff --git a/test/enemies.test.ts b/test/enemies.test.ts index 5458470..b51bcef 100644 --- a/test/enemies.test.ts +++ b/test/enemies.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { ACTIONS, isAvailable } from '../src/sim/actions' -import { planIntents, stepToward } from '../src/sim/enemies' +import { PLAN_AHEAD, planIntents, stepToward } from '../src/sim/enemies' import { enemyCount, enemyKinds, generateLevel, SAFE_DISTANCE, START } from '../src/sim/levelgen' import { newRun, PAR_BONUS } from '../src/sim/state' import { EXIT_SPAWN, step } from '../src/sim/turn' @@ -35,16 +35,17 @@ describe('levelgen: enemies and exit', () => { expect(enemyKinds(50)).toEqual(['blob', 'seeker', 'drifter']) }) - it('a new level already shows every enemy intent', () => { + it('a new level already shows every enemy plan, up to 3 moves', () => { const s = newRun(5) expect(s.enemies.length).toBeGreaterThan(0) - expect(s.enemies.some((e) => e.intent)).toBe(true) + expect(s.enemies.some((e) => e.plan.length === PLAN_AHEAD)).toBe(true) + expect(s.enemies.every((e) => e.plan.length <= PLAN_AHEAD)).toBe(true) }) }) -/** Give every enemy an intent of one step in a direction. */ +/** Give every enemy a one-move plan: the nth enemy steps to the nth target next turn. */ function intend(s: GameState, ...targets: Vec[]): GameState { - s.enemies.forEach((e, i) => (e.intent = targets[i]!)) + s.enemies.forEach((e, i) => (e.plan = [targets[i]!])) return s } @@ -124,12 +125,12 @@ describe('enemy movement', () => { #####`) s.enemies[0]!.kind = 'drifter' planIntents(s) - expect(s.enemies[0]!.intent).toEqual(v(2, 1)) + expect(s.enemies[0]!.plan.slice(0, 2)).toEqual([v(2, 1), v(3, 1)]) s.enemies[0]!.pos = v(3, 1) + s.enemies[0]!.plan = [] s.cells[1 * s.width + 2] = 'block' // behind it, so the only way on is down planIntents(s) - expect(s.enemies[0]!.intent).toEqual(v(3, 2)) - expect(s.enemies[0]!.dir).toBe('down') + expect(s.enemies[0]!.plan.slice(0, 2)).toEqual([v(3, 2), v(3, 3)]) }) it('an enemy with nowhere to go stays put', () => { @@ -138,7 +139,7 @@ describe('enemy movement', () => { #B#P# #####`) planIntents(s) - expect(s.enemies[0]!.intent).toBeNull() + expect(s.enemies[0]!.plan).toEqual([]) }) it('the same state always plans the same intents', () => { @@ -149,6 +150,101 @@ describe('enemy movement', () => { }) }) +describe('plans three moves ahead', () => { + it('an enemy makes exactly the moves it showed', () => { + let s = newRun(5) + const e = s.enemies.find((e) => e.plan.length === PLAN_AHEAD)! + const shown = [...e.plan] + for (let t = 0; t < PLAN_AHEAD; t++) { + s = step(s, { id: 'wait' }).state + if (s.status !== 'playing') return + expect(s.enemies.find((x) => x.id === e.id)!.pos).toEqual(shown[t]) + } + }) + + it('tops the plan up to 3 every turn', () => { + let s = newRun(5) + s = step(s, { id: 'wait' }).state + expect(s.enemies.some((e) => e.plan.length === PLAN_AHEAD)).toBe(true) + }) + + it('a later move the player blocks is dropped, with everything after it, and planned again', () => { + const s = board(` + ######## + #P.....# + ########`) + s.enemies.push({ id: 0, kind: 'drifter', pos: v(6, 1), dir: 'left', plan: [v(5, 1), v(4, 1), v(3, 1)], spawned: false }) + s.cells[1 * s.width + 4] = 'block' // the second move is now blocked + planIntents(s) + // Keeps (5,1); the Drifter, blocked going left, bounces back and forth from there. + expect(s.enemies[0]!.plan).toEqual([v(5, 1), v(6, 1), v(5, 1)]) + }) + + it('frozen enemies keep their plans', () => { + const s = intend(board(` + ###### + #P..B# + ######`), v(3, 1)) + s.charges.freeze = 1 + const { state } = step(s, { id: 'freeze' }) + expect(state.enemies[0]!.plan[0]).toEqual(v(3, 1)) + }) +}) + +describe('lingering flame', () => { + it('stays burning for the next turn, then goes out', () => { + let s = board(` + ####### + #P..1.# + #######`) + s = step(s, { id: 'wait' }).state + expect(s.burning.sort((a, b) => a - b)).toEqual([7 + 3, 7 + 4, 7 + 5]) + s = step(s, { id: 'wait' }).state + expect(s.burning).toEqual([]) + }) + + it('kills an enemy that walks into it the turn after', () => { + let s = board(` + ######## + #P..1.B# + ########`) + s = step(s, { id: 'wait' }).state // explodes: tiles 3-5 burn; the Blob at 6 is untouched + expect(s.enemies).toHaveLength(1) + s.enemies[0]!.plan = [v(5, 1)] + const { state, events } = step(s, { id: 'wait' }) + expect(state.enemies).toEqual([]) + expect(events.map((e) => e.type)).toContain('enemyDied') + }) + + it('the player cannot step or put a bomb into it', () => { + let s = board(` + ####### + #P.1..# + #######`) + s = step(s, { id: 'wait' }).state + expect(s.status).toBe('playing') + expect(ACTIONS.move.validTargets(s)).toEqual([]) + expect(ACTIONS.bomb.validTargets(s)).toEqual([]) + }) + + it('does not burn the drops its own kills left', () => { + let found = false + for (let r = 1; r < 200 && !found; r++) { + let s = board(` + ####### + #P..1B# + #######`) + s.dropRng = r + s = step(s, { id: 'wait' }).state + if (!s.drops.length) continue + found = true + const after = step(s, { id: 'wait' }) + expect(after.state.drops.length).toBe(s.drops.length) + } + expect(found).toBe(true) + }) +}) + describe('drops', () => { it('walking onto coins collects them', () => { const s = board(` diff --git a/test/helpers.ts b/test/helpers.ts index c59abc7..178debc 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -6,8 +6,8 @@ const ENEMIES: Record<string, EnemyKind> = { B: 'blob', S: 'seeker', D: 'drifter /** * A state from a picture: `#` wall, `+` soft block, `.` floor, `P` player (on floor), * `1`–`9` a bomb with that timer (on floor, placed on an earlier turn), - * `B` / `S` / `D` a Blob / Seeker / Drifter facing right with no intent (it stays put unless the - * test sets one), `E` the exit uncovered, `e` the exit under a soft block, `$` a 1-coin drop. + * `B` / `S` / `D` a Blob / Seeker / Drifter facing right with an empty plan (it stays put unless + * the test gives it one), `E` the exit uncovered, `e` the exit under a soft block, `$` a 1-coin drop. * Without `E` or `e` the exit is off the board, so it never opens. */ export function board(picture: string, stats: Partial<Stats> = {}): GameState { @@ -20,7 +20,7 @@ export function board(picture: string, stats: Partial<Stats> = {}): GameState { seed: 1, level: 1, turn: 5, width, height, cells, player: { x: 0, y: 0 }, lives: START_LIVES, stats: { ...START_STATS, ...stats }, bombs: [], status: 'playing', enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 100, coins: 0, - charges: noCharges(), shield: false, frozen: false, aiRng: 1, dropRng: 1, + charges: noCharges(), shield: false, frozen: false, decoy: null, burning: [], aiRng: 1, dropRng: 1, } rows.forEach((row, y) => { if (row.length !== width) throw new Error(`row ${y} is ${row.length} wide, not ${width}`) @@ -29,7 +29,7 @@ export function board(picture: string, stats: Partial<Stats> = {}): GameState { if (ch === 'P') player = { x, y } if (/[1-9]/.test(ch)) s.bombs.push({ pos: { x, y }, timer: Number(ch), fire: s.stats.fire, placedTurn: 0 }) const kind = ENEMIES[ch] - if (kind) s.enemies.push({ id: s.nextEnemyId++, kind, pos: { x, y }, dir: 'right', intent: null, spawned: false }) + if (kind) s.enemies.push({ id: s.nextEnemyId++, kind, pos: { x, y }, dir: 'right', plan: [], spawned: false }) if (ch === 'E' || ch === 'e') s.exit = { x, y } if (ch === '$') s.drops.push({ pos: { x, y }, kind: 'coins', amount: 1 }) }) diff --git a/test/path.test.ts b/test/path.test.ts new file mode 100644 index 0000000..a6a7a01 --- /dev/null +++ b/test/path.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { playerPath } from '../src/sim/path' +import { board, v } from './helpers' + +describe('auto-move path', () => { + it('is the shortest walk round walls and crates, ending at the destination', () => { + const s = board(` + ####### + #P.+..# + #.#.#.# + #.....# + #######`) + expect(playerPath(s, v(5, 1))).toEqual([v(1, 2), v(1, 3), v(2, 3), v(3, 3), v(4, 3), v(5, 3), v(5, 2), v(5, 1)]) + }) + + it('goes round bombs', () => { + const s = board(` + ##### + #P3.# + #...# + #####`) + expect(playerPath(s, v(3, 1))).toEqual([v(1, 2), v(2, 2), v(3, 2), v(3, 1)]) + }) + + it('is null when the destination is walled off, blocked, or where the player stands', () => { + const s = board(` + ###### + #P.+.# + ######`) + expect(playerPath(s, v(4, 1))).toBeNull() + expect(playerPath(s, v(3, 1))).toBeNull() + expect(playerPath(s, v(1, 1))).toBeNull() + }) + + it('goes through enemies, which will have moved by then', () => { + const s = board(` + ###### + #PB..# + ######`) + expect(playerPath(s, v(4, 1))).toEqual([v(2, 1), v(3, 1), v(4, 1)]) + }) +}) diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index f341cee..56f2232 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -19,12 +19,29 @@ const press = (...keys) => async (page) => { } } +/** The screen position of a tile's centre, while the camera is at the left edge of the level. */ +async function tileCentre(page, x, y) { + return page.evaluate(([x, y]) => { + const top = document.getElementById('hud').getBoundingClientRect().height + const bottom = document.getElementById('actions').getBoundingClientRect().height + const size = (innerHeight - top - bottom) / 13 + return { x: (x + 0.5) * size, y: top + (y + 0.5) * size } + }, [x, y]) +} + +const tap = (x, y) => async (page) => { + const p = await tileCentre(page, x, y) + await page.mouse.click(p.x, p.y) +} + const SHOTS = [ { name: 'start', act: async () => {} }, // Bomb picked: the tiles it can go on are highlighted. { name: 'bomb-picked', act: async (page) => { await page.click('[data-action=bomb]') } }, // A bomb placed to the right, then a step down out of its way: its countdown and the danger overlay. { name: 'countdown', act: press('KeyB', 'KeyD', 'KeyS') }, + // The bomb has gone off: its flame lingers on the board through the next turn. + { name: 'lingering', act: press('KeyB', 'KeyD', 'KeyS', 'Space', 'Space') }, // Walked to the right a little way: the camera follows. { name: 'scrolled', act: press('KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD') }, // Standing next to our own bomb as it goes off. @@ -32,6 +49,20 @@ const SHOTS = [ { name: 'desktop', viewport: DESKTOP, act: press('KeyB', 'KeyD', 'KeyS') }, // Level 13 has all three enemy types so far, each with its intent arrow. { name: 'enemies', query: 'level=13', act: press('KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD') }, + // Tapped a far tile: the flag, the walk there, and Move turned into Go; then one Go. + { name: 'auto-move', act: async (page) => { await tap(3, 3)(page); await page.click('[data-action=move]') } }, + // Held a finger on the tile to the right: a bomb is armed there, with its blast, until release. + { + name: 'long-press', + act: async (page) => { + const p = await tileCentre(page, 2, 1) + await page.mouse.move(p.x, p.y) + await page.mouse.down() + await page.waitForTimeout(600) + }, + }, + // A Pierce bomb (spikes badge) placed to the right, then a Decoy (pale copy of the player) below. + { name: 'pierce-decoy', query: 'charges=2', act: press('KeyI', 'KeyD', 'KeyY', 'KeyS') }, // Holding action charges: their buttons appear; Throw picked shows where a bomb can land. { name: 'charges', query: 'charges=2', act: async (page) => { await page.click('[data-action=throw]') } }, ]