explodorama / commit
One board, Immortal, moves animated from state, M for Move
CHANGELOG.md | 23 +++++--- CONTRIBUTING.md | 5 ++ PLAN.md | 33 ++++++++---- README.md | 40 +++++++------- decisions.md | 66 +++++++++++++++++++++++ docs/screenshots/almanac-charges.png | Bin 369587 -> 266721 bytes docs/screenshots/almanac-enemies.png | Bin 308803 -> 193330 bytes docs/screenshots/almanac.png | Bin 430979 -> 376988 bytes docs/screenshots/auto-move.png | Bin 244640 -> 186278 bytes docs/screenshots/bomb-picked.png | Bin 243597 -> 186269 bytes docs/screenshots/charges.png | Bin 256659 -> 199175 bytes docs/screenshots/countdown.png | Bin 248448 -> 190124 bytes docs/screenshots/death.png | Bin 192554 -> 156855 bytes docs/screenshots/demo.png | Bin 108596 -> 113542 bytes docs/screenshots/desktop.png | Bin 583108 -> 276016 bytes docs/screenshots/dev.png | Bin 245795 -> 188476 bytes docs/screenshots/enemies.png | Bin 238019 -> 189779 bytes docs/screenshots/gem-offer.png | Bin 201285 -> 184236 bytes docs/screenshots/gem.png | Bin 244258 -> 184092 bytes docs/screenshots/info-enemy.png | Bin 181623 -> 125480 bytes docs/screenshots/info-hud.png | Bin 172465 -> 126838 bytes docs/screenshots/info-mode.png | Bin 242881 -> 185399 bytes docs/screenshots/lingering.png | Bin 248488 -> 189020 bytes docs/screenshots/long-press.png | Bin 247115 -> 185568 bytes docs/screenshots/menu.png | Bin 170738 -> 125870 bytes docs/screenshots/move-picked.png | Bin 243624 -> 186317 bytes docs/screenshots/new-run.png | Bin 179557 -> 148597 bytes docs/screenshots/nuke.png | Bin 264697 -> 209394 bytes docs/screenshots/scrolled.png | Bin 246763 -> 0 bytes docs/screenshots/small-desktop.png | Bin 271684 -> 0 bytes docs/screenshots/small.png | Bin 186731 -> 0 bytes docs/screenshots/start.png | Bin 243146 -> 185572 bytes src/main.ts | 101 +++++++++++++++++++---------------- src/render/board.ts | 79 ++++++++++++++------------- src/render/camera.ts | 8 +-- src/render/effects.ts | 59 ++++++++++++++++++-- src/sim/demo.ts | 2 +- src/sim/levelgen.ts | 69 ++++++++---------------- src/sim/record.ts | 2 +- src/sim/state.ts | 6 +-- src/sim/turn.ts | 13 +++-- src/sim/types.ts | 24 ++++----- src/style.css | 11 +++- src/ui/controls.ts | 18 +++---- src/ui/info.ts | 19 ++++--- test/animation.test.ts | 57 ++++++++++++++++++++ test/danger.test.ts | 4 +- test/options.test.ts | 58 ++++++++++---------- test/turn.test.ts | 3 +- tools/screenshots.mjs | 5 -- 50 files changed, 446 insertions(+), 259 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d32601a..0b1257a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,8 @@ saved before the change is dropped and the next visit starts a new run. ### Added -- **The board and the turn.** A 31 × 13 board with a stone outer wall, indestructible pillars - (their colour changes from level to level) and wooden crates; the camera follows the player - sideways. Each turn the player picks one action, then enemies step, bombs count down and +- **The board and the turn.** A 9 × 11 board with a stone outer wall, indestructible pillars + (their colour changes from level to level) and wooden crates; the whole board fits the screen. Each turn the player picks one action, then enemies step, bombs count down and explode, and enemies show their next step. - **Bombs** whose fuses count down with the player's moves, chain reactions, and a danger preview that shows what will burn before an action is committed. Flame lingers for one turn. @@ -36,7 +35,7 @@ saved before the change is dropped and the next visit starts a new run. - **A gem on every level**, hidden under a crate. Taking it offers 5 different power-ups, and you choose one (no turn passes). Common power-ups are offered more often than rare ones, and ones you already hold aren't offered. -- **The 50-level run** with a victory banner at the end. +- **The 20-level run** with a victory banner at the end. - **Mid-run saving** after every action, in the browser. URLs with test parameters (`?seed`, `?level`, `?charges`, `?small`, `?dev`, `?god`, `?crumble`) never load or overwrite the save. - **Touch controls:** tap a neighbouring tile to step, press and hold one to bomb it, tap the @@ -47,8 +46,7 @@ saved before the change is dropped and the next visit starts a new run. almanac of power-ups, charges and enemies. - **"What is that?"** — the **?** button (or the ? key), then a tap on anything, explains it; things you can walk to offer **Walk there**. -- **New run options:** a Normal board (31 × 13, 50 levels) or a Small one (9 × 11, 20 levels), - plus Developer, God and Crumbling crates modes. +- **New run modes:** Developer and Immortal. - **Pickup dialogs:** the first pickup of each kind of action charge in a run opens a dialog saying what it does. - **Animation** for the player, enemies, bombs, crates, flame, drops, power-ups, top-bar numbers @@ -67,6 +65,13 @@ saved before the change is dropped and the next visit starts a new run. ### Changed +- One board, 9 × 11 with 20 levels (it was a choice with the original's 31 × 13 and 50 levels), + and crates always crumble once the enemies are gone (it was an option). The New run menu has + just two modes: Developer and Immortal (formerly God: nothing kills you, so you can reach the + end, but an Immortal run earns no achievements). +- A Developer run's achievements count, marked with a red border; its victory banner is framed + in red. +- The demo's title uses a readable font. - The placeholder shapes were replaced with Kenney sprites. - The action bar is now only Move, Bomb and Wait. Upgrades make Move and Bomb do more instead of adding buttons, and charges have no buttons at all. @@ -81,9 +86,11 @@ saved before the change is dropped and the next visit starts a new run. - The top bar has a fixed number of rows, so the board no longer moves as things are picked up. - The Shield's ring is smaller and moves with the player. - Stepping onto the exit walks into the doorway before the jump (it used to happen instantly). +- The player walking into flame is animated (they used to appear on the tile at once). Every + move on screen now comes from the change of state, so nothing can skip it. - An enemy walking into flame finishes its step before it dies (it used to die on the new tile without moving). -- Saved runs from before the gems can't be resumed (`SIM_VERSION` 19). +- Saved runs from before the gems can't be resumed (`SIM_VERSION` 20). - Developer mode now only adds an extra gem next to the player on every level (the level's own gem stays hidden); it no longer gives every ability, extra stats or unlimited charges. @@ -106,6 +113,8 @@ saved before the change is dropped and the next visit starts a new run. - Drops no longer land on the uncovered exit. - Auto-move uses Run and Teleport: each move goes as far toward the target as it can (a neighbour is still a step). +- Once every enemy is gone, auto-move walks twice as fast (two steps a second). +- The Move button's key is M (like B for Bomb); with Diagonal, Q E Z C step diagonally at once. - With Diagonal, tapping a diagonal neighbour steps there (it used to walk round the corner). - Throw no longer goes over a pillar or the outer wall (it still goes over crates, bombs and enemies). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67ae61c..5c7cea0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,6 +57,11 @@ The README's *How it's built* section describes the layout. The rules that matte rather than replayed wrongly. Note the bump in the changelog. - **Rendering** (`src/render/`) and the **HTML UI** (`src/ui/`) only read the state and the events that `step()` returns; they never change the rules. +- **Movement on screen comes from the change of state, not from events.** The player's and the + enemies' moves are worked out from their tile before and after a turn (`playerTransition` in + `render/effects.ts`), and a pose (hit, jump, crouch, dying) starts only once the move is done. + Something else happening in the same turn then can't skip the move. Keep new animations to + that pattern, and add a case to `test/animation.test.ts`. ## Art and other assets diff --git a/PLAN.md b/PLAN.md index 7bf8b6b..8bc343d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,6 +1,6 @@ # Explodorama — Plan -A turn-based, mobile-first, roguelike take on the 1985 NES *Bomberman*: 50 levels and an ending. +A turn-based, mobile-first, roguelike take on the 1985 NES *Bomberman*: 20 levels (50 until 2026-10-23) and an ending. Built with **LittleJS + TypeScript + Vite**, tested with **Vitest**, deployed to **GitLab Pages**. Status: **M0–M5 done, M6 under way** (see §18). This document is the design and the hand-off @@ -25,7 +25,7 @@ turn), §15 (notes for the implementer) and §18 (milestones). Reasons for decis | 9 | Death | **The player chooses** after each death: **Keep** (keep Bomb+, Fire+, Run) or **Fresh** (lose them, get coins). Balanced so most players would pick each about half the time. See §9. | | 10 | Curses | **Exist, but only appear after the first shop** (level 2 onward). See §6. | | 11 | Reapers / turn limit | **Later** (post-MVP). The par-turn coin bonus stays. | -| 12 | Run length | **50 levels with an ending**, like the NES. A win produces a **victory certificate with a QR code** for that seed. See §11. | +| 12 | Run length | **50 levels with an ending**, like the NES. A win produces a **victory certificate with a QR code** for that seed. See §11. *Changed 2026-10-23 (user request): 20 levels, on the one 9 × 11 board.* | | 13 | Art | **Kenney's New Platformer Pack 1.1** (CC0): sprites for the player, enemies, board, pickups, HUD and buttons. *Changed 2026-10-20 at the user's request; it replaces the earlier plan of Icongeek26 Flaticon icons.* See §13. | Still open: see §14. @@ -114,7 +114,7 @@ anything free before spending a charge. ~~Picking an action opens a **strip abov saying what it does now~~ *Removed 2026-10-23 (user request):* first it covered the board's bottom row, then a band kept free for it kept resizing the board. There is no explanation text during play: power-ups are explained in the gem's choice, charges on their first pickup, and -"?" then a button explains it (`actionInfo` in `ui/info.ts`). The old keys stay (R, T, P, G, I, O, L pick that one +"?" then a button explains it (`actionInfo` in `ui/info.ts`). *Since 2026-10-23 (user request)* the Move button's key is **M** (it said "WASD", untrue with Diagonal), as B is Bomb's; W A S D / arrows still step at once, and with Diagonal Q E Z C step diagonally at once. The old keys stay (R, T, P, G, I, O, L pick that one action). With an action picked, a direction key uses the tile next to the player, or 2 away if that one can't be used (a run, a throw). @@ -450,6 +450,12 @@ contact. Intent shown every turn. Adapted from the NES eight: ## 11. Levels, scrolling, progression, the ending +*Since 2026-10-23 (user request):* **one board, 9 × 11, 20 levels**, fitting the screen whole — +the former "Small" board (density 25%, enemies 2 + 1 per 4 levels up to 5, par 25 + 8/enemy) — +with no scrolling, and crates always crumbling once the last enemy is gone (§13b). The NES-size +notes below (31 × 13, 50 levels, the side-scrolling camera) are kept as the history of the +design; the board code for them was removed. + - **Grid 31 × 13** like the NES: hard walls around the border, indestructible pillars at every even (x, y), soft blocks placed by seeded RNG at ~30–40% density, start corner (3 tiles in an L) always clear, no enemy within ~4 tiles of the start. (Open, §14: narrower early levels?) @@ -473,6 +479,11 @@ contact. Intent shown every turn. Adapted from the NES eight: ### Victory certificate +*Since 2026-10-23 (user request):* an **Immortal** run (§13b) can be played to the end but earns +no certificate, nor any other achievement. +A **Developer** run earns them all, but each shows a **red border** (user request, 2026-10-23, +"like the red quarters some bars used in jukeboxes"); its victory banner is framed in red already. + Shows: **seed, date, turns, deaths, Keep/Fresh choices, Workshop loadout, sim version**, and a **QR code** linking to `https://pavelsafronov.gitlab.io/explodorama/#cert=…`. @@ -622,15 +633,19 @@ Chosen in the **New run** dialog (menu, or after game over / victory), remembere `localStorage` for the next new run, fixed for the run, stored in its record (`RunRecord.options`) and state (`GameState.options`), so replays are exact. Shown as tags in the top bar and in the menu. +*Since 2026-10-23 (user request) only two options remain, Developer and Immortal:* the board +size and Crumbling crates are gone (one 9 × 11 board, §11; crates always crumble). URL test +parameters: `?dev`, `?immortal`. + | Option | Rules | |---|---| -| **Board: Normal** | 31 × 13, density 35%, 50 levels, enemies 3 + 1 per 3 levels (max 10), par 50 + 10/enemy. | -| **Board: Small** | 9 × 11 (fits a phone whole; the camera scales to fit both ways, no scrolling), density 25%, **20 levels**, enemies 2 + 1 per 4 levels (max 5), par 25 + 8/enemy. Same enemy-type schedule and Run rule. Levelgen tops up crates if fewer than 2 are far enough for the exit and power-up. | -| **Developer** | *Since 2026-10-23 (user request), only this:* an **extra** gem (`PowerUp.extra`, another colour, its own offer) lies right of the start, every level and after every death, to try the choice at once; the level keeps its own gem, hidden as usual (at first it was that gem, moved). (Until then: every ability and pass, 5 bombs, blast 5, unlimited charges shown as ∞; **killable**, split from God mode by the user.) | -| **God** | The player can't be hurt at all. | -| **Crumbling crates** | When the last enemy dies, every crate gets a countdown of 1–3 turns (seeded, `hash(seed, "crumble", level)`), shown by shaking and fading; at 0 it is gone, revealing whatever was under it. It is not flame: no punishment, nothing burned. | +| ~~**Board: Normal**~~ | *Removed 2026-10-23.* 31 × 13, density 35%, 50 levels, enemies 3 + 1 per 3 levels (max 10), par 50 + 10/enemy. | +| ~~**Board: Small**~~ | *Now the only board (no longer an option).* 9 × 11 (fits a phone whole; the camera scales to fit both ways, no scrolling), density 25%, **20 levels**, enemies 2 + 1 per 4 levels (max 5), par 25 + 8/enemy. Same enemy-type schedule. Levelgen tops up crates if fewer than 2 are far enough for the exit and power-up. | +| **Developer** | *Since 2026-10-23 (user request), only this:* an **extra** gem (`PowerUp.extra`, another colour, its own offer) lies right of the start, every level and after every death, to try the choice at once; the level keeps its own gem, hidden as usual (at first it was that gem, moved). Achievements count, with a red border (the victory banner is framed in red). (Until then: every ability and pass, 5 bombs, blast 5, unlimited charges shown as ∞; **killable**, split from God mode by the user.) | +| **Immortal** (called God until 2026-10-23) | The player can't be hurt at all, so the run can be played to the end — but **an Immortal run earns no achievements** (the victory certificate, the Workshop's rewards, when they exist). | +| ~~**Crumbling crates**~~ | *Always on since 2026-10-23 (no longer an option).* When the last enemy dies, every crate gets a countdown of 1–3 turns (seeded, `hash(seed, "crumble", level)`), shown by shaking and fading; at 0 it is gone, revealing whatever was under it. It is not flame: no punishment, nothing burned. | -URL test parameters `?small`, `?dev`, `?god`, `?crumble` set them for a test board. +URL test parameters `?small`, `?dev`, `?god`, `?crumble` set them for a test board (until 2026-10-23; now `?dev`, `?immortal`). --- diff --git a/README.md b/README.md index 9231af9..2cb11e2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ counts down with *your* moves, so nothing happens until you act — and you can what will burn before you commit. Status: **early development.** The board, bombs, lives, the first three enemy types, coins, all -seven charges that work when picked up, a hidden gem on every level that offers a choice of power-ups, the exit, the 50-level run with its ending, and saving +seven charges that work when picked up, a hidden gem on every level that offers a choice of power-ups, the exit, the 20-level run with its ending, and saving mid-run work. The shop, the Keep / Fresh choice on death, curses and more enemies are planned — see [PLAN.md](PLAN.md). @@ -51,7 +51,7 @@ they make Move and Bomb do more. | Action | Phone | Keyboard | What it does | |---|---|---|---| -| **Move** | Tap a tile next to you, or tap **Move** and then any highlighted tile | W A S D or arrow keys | Step one tile up, down, left or right onto an empty tile. **With the Run power-up:** or 2 tiles in a straight line. **With Teleport:** or any empty tile up to 3 steps away, over anything. **With Kick:** walking into a bomb kicks it. **With Diagonal:** or a diagonal step. | +| **Move** | Tap a tile next to you, or tap **Move** and then any highlighted tile | **M**, then a direction — or W A S D / arrow keys straight away (Q E Z C diagonally, with Diagonal) | Step one tile up, down, left or right onto an empty tile. **With the Run power-up:** or 2 tiles in a straight line. **With Teleport:** or any empty tile up to 3 steps away, over anything. **With Kick:** walking into a bomb kicks it. **With Diagonal:** or a diagonal step. | | **Bomb** | Tap **Bomb**, then a highlighted tile — or **press and hold** a tile next to you | **B**, then a direction | Place a bomb on an empty tile next to you. You can have 1 bomb on the board at a time. **With Throw:** or 2 tiles away in a straight line, over a crate, bomb or enemy (not a pillar). Pierce, Power and Nuke charges change your next bombs by themselves (see Charges). | | **Wait** | Tap **Wait**, or tap **yourself** | Space | Do nothing; the world takes its turn. | @@ -73,8 +73,8 @@ exit is where you are going. If the only way somewhere is over the open exit, au 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 destination wait for you to deal with it (Go falls back to picking Move, so you can step aside yourself). -**Once every enemy is gone**, the way needs no more presses: you walk there by yourself, one -step a second (still one turn per step). +**Once every enemy is gone**, the way needs no more presses: you walk there by yourself, two +steps a second (still one turn per step). **How far one move of auto-move goes** (and the first one, when you tap a far tile): a tile next to you is always a step. Otherwise it's whichever of a step, a **Run** (2 tiles straight along the @@ -178,7 +178,7 @@ Flame kills enemies. | **Seeker** | fly | level 7 | Half the time takes the shortest way toward you; otherwise wanders like a Blob. | | **Drifter** | spinning saw | level 13 | Goes straight until blocked (or another enemy is heading there), then turns. | -Level 1 has 3 enemies, with one more every 3 levels (at most 10). Enemies never start within +Level 1 has 2 enemies, with one more every 4 levels (at most 5). Enemies never start within 4 tiles of you. ### Drops and coins @@ -202,7 +202,7 @@ open, so pick them up soon. Coins will buy upgrades in the shop (not built yet). toughest type). Those drop nothing. They can't be hurt by the flame that let them out (it lingers a turn), but a new explosion kills them as usual. - **Par:** the top bar shows `turns taken / par`. Finish within par for a bonus of **5 coins**. - Par is 50 turns plus 10 per enemy on the level. + Par is 25 turns plus 8 per enemy on the level. ### Power-ups @@ -228,7 +228,7 @@ before moving on. | **Kick** | bomb with a right-arrow badge | Walk into a bomb next to you and it slides away until the next tile is blocked (a wall, crate, bomb or enemy), keeping its number. | | **Throw** | bomb with an up-right-arrow badge | **Bomb** can also go exactly 2 tiles away in a straight line, over a crate, bomb or enemy in between — but not over a pillar or the outer wall (or **T**, then a direction). | | **Teleport** (rare) | the Move figure with an outbox badge | **Move** can take you to any empty tile within 3 steps (counting across and down), over anything (or **P**, then tap a tile). | -| **Diagonal** | the Move figure with a diagonal-arrows badge | **Move** can also step diagonally, even squeezing between two blocked tiles (or **G**, then **Q E Z C**: up-left, up-right, down-left, down-right). A charge until 2026-10-23. | +| **Diagonal** | the Move figure with a diagonal-arrows badge | **Move** can also step diagonally, even squeezing between two blocked tiles (or **Q E Z C**: up-left, up-right, down-left, down-right). A charge until 2026-10-23. | | **Detonator** | bomb with a ! badge | Your bombs pulse with a red glow: **tap one** and it goes off this turn (or **X**, then tap it). No button. | | **Wall Pass** | crate with an ✕ badge | Walk through crates. | | **Bomb Pass** | bomb with a shield badge | Walk over bombs. | @@ -250,17 +250,15 @@ power-up (abilities and passes included) and what your charges still give. (The the passes and the Detonator away; since 2026-10-23 a death costs only the life.) With none left, the run is over and the seed is shown, so the same board can be replayed. -### New run: board size and modes +### New run: modes -**Menu → New run** (or the button after game over / victory) lets you choose: +**Menu → New run** (or the button after game over / victory) starts a new run, with two optional +modes (there is one board size, and crates always crumble once the enemies are gone): -| Choice | What it does | +| Mode | What it does | |---|---| -| **Normal** board | 31 × 13, scrolls sideways, **50 levels**. | -| **Small** board | 9 × 11, fits the screen whole (no scrolling), **20 levels**, fewer enemies (2 on level 1, up to 5) and fewer crates. A quicker game. | -| **Developer** (checkbox) | **An extra gem lies right next to you** at the start of every level (and after a death), in another colour, to try the power-up choice at once. The level's own gem is still hidden under a crate as usual. Nothing else changes. | -| **God** (checkbox) | Nothing can kill you. | -| **Crumbling crates** (checkbox) | Once the last enemy is gone, every crate crumbles away over 1–3 turns (shaking first), revealing the exit and the gem. Not flame: nothing is punished. | +| **Developer** (checkbox) | **An extra gem lies right next to you** at the start of every level (and after a death), in another colour, to try the power-up choice at once. The level's own gem is still hidden under a crate as usual. Achievements still count, but with a red border (the victory banner is framed in red), like the red quarters some bars kept for their own jukebox plays. | +| **Immortal** (checkbox) | Nothing can kill you, so you can play through to the end — but an Immortal run earns no achievements. (It was called God.) | The choice is remembered for next time, is fixed for the run, and shows as small yellow tags at the start of the top bar's last row (tap them for details) and in the menu. Starting a new run while one is @@ -268,7 +266,7 @@ going warns that it will be lost. ### The run -**50 levels** (20 on a small board). Clearing the last level ends the run with a victory banner: total turns, lives lost +**20 levels.** Clearing the last level ends the run with a victory banner: total turns, lives lost and the seed. The game **saves after every move** (in this browser), so closing the tab and coming back continues the run where you left it. @@ -282,9 +280,11 @@ scrolls sideways instead of growing, so **the board never moves** as you pick th ### The board -31 × 13 tiles, like the original: a stone outer wall and metal pillars (indestructible; the -pillars change colour from level to level), wooden crates as soft blocks (destructible), and the -camera follows you sideways. +9 × 11 tiles, fitting the screen whole (no scrolling): a stone outer wall and metal pillars +(indestructible; the pillars change colour from level to level) and wooden crates as soft +blocks (destructible). **Once the last enemy is gone, every crate crumbles away** over 1–3 turns +(shaking first), revealing the exit and the gem — not flame, so nothing is punished. (The +original's 31 × 13 board with 50 levels was an option until 2026-10-23; so was the crumbling.) ## Running it @@ -300,7 +300,7 @@ npm run screenshots # build, then photograph the game into docs/screenshots/ (P URL parameters: `?seed=123` plays a fixed board; `?level=4` starts at a later level (level 13 has all three enemy types); `?charges=2` starts as if 2 of each lasting charge had been picked up -(6 Pierce, Power and Nuke bombs, and a Shield), for trying them out; `?small`, `?dev`, `?god` and `?crumble` set the board size and modes; `?demo` shows the demo, which test URLs otherwise skip (with `?seed`, the same demo every time). A URL with any of these parameters plays a test board: it doesn't load or overwrite your +(6 Pierce, Power and Nuke bombs, and a Shield), for trying them out; `?dev` and `?immortal` set the modes; `?demo` shows the demo, which test URLs otherwise skip (with `?seed`, the same demo every time). A URL with any of these parameters plays a test board: it doesn't load or overwrite your saved run. Deployed to GitLab Pages by `.gitlab-ci.yml` on every push to the default branch. diff --git a/decisions.md b/decisions.md index 8e0f7c4..50ad0dc 100644 --- a/decisions.md +++ b/decisions.md @@ -952,3 +952,69 @@ stream) so it doesn't offer the same five as the level's gem. The level's own ge under its crate exactly as in a normal run. Burning, revealing, taking and drops' "never on a gem" work per gem. Tests: the level's gem is unchanged and hidden; the extra one is another colour and its offer differs on most seeds; a normal run has one gem. `SIM_VERSION` 19. + +### 2026-10-23 — One board; crates always crumble; God becomes Immortal + +User request: move away from the "Normal" and "Small" labels, make the small board the main one, +make crate crumbling the default and drop it from the options, so the New run menu has only the +two modes; and rename God to **Immortal**, noting that it earns no achievements but does allow +playing to the end. + +- **One board** (user's choice of "delete the big one entirely" over keeping it behind a test + URL): 9 × 11, 20 levels, density 25%, 2 enemies on level 1 + 1 per 4 levels up to 5, par + 25 + 8 per enemy — the former Small board. `RunOptions.size`, `BoardSize`, `SIZES` and the + big board's numbers are gone; the camera always fits the whole board. This changes recorded + decision #12 ("50 levels, like the NES") and PLAN.md §11's NES-size board: both are marked + as changed, not deleted. The screenshots `small`, `small-desktop` and `scrolled` are gone. +- **Crates always crumble** once the last enemy is gone (`RunOptions.crumble` removed). +- **Immortal** (`RunOptions.immortal`, was `god`): nothing can kill the player, so a run can be + played to the end, but it earns no achievements — said in the New run dialog, the menu and its + tag; there are no achievements yet, so PLAN.md notes it for the victory certificate and the + Workshop. Saved menu choices with the old `god` key still read as Immortal. +- The New run dialog now has just Developer and Immortal; URL parameters are `?dev` and + `?immortal`. `SIM_VERSION` 20. + +### 2026-10-23 — A readable demo title; Developer runs' achievements in red + +- The demo's "Explodorama" title was hard to read in Kenney Future (blocky letters, an X like an + H). It now uses the rounded system font the game's text uses, extra bold. Kenney Future stays + for buttons and dialog titles, where it is smaller. +- User request: a Developer run earns achievements like any run, but each carries a **red + border**, "like the red quarters some bars used in jukeboxes". There are no achievements yet, + so for now the victory banner of a Developer run is framed in red and says "(Developer run)"; + PLAN.md records the rule for the certificate and the rest. (Immortal runs earn none.) The New + run dialog and the run's info say so. Also fixed: the victory banner said "1 turns". + +### 2026-10-23 — Moves are animated from the change of state + +User report: in the demo, the player walking into flame jumped straight onto the tile — and +"this issue has happened before (no animation upon a movement); can this pattern be made more +resilient?" It had: the step into the doorway, an enemy walking into flame, and now the player +into flame. Each time, something else in the same turn (the level cleared, a death) replaced +the step's animation, because the player had one animation slot, set from events. + +Now movement and pose are separate. **The move comes from the state**: the player's tile before +and after the turn (`playerTransition`, a pure function in effects.ts), drawn as a slide or, for +a teleport, vanishing and reappearing. **The pose** — hit, jump, crouch — is drawn only once the +move is done. Nothing in the turn can skip a move any more. Enemies' steps are likewise taken +from their change of position (and a dying enemy's last step too), not from events. A new level +or a retry starts still. Tests (`test/animation.test.ts`): a step into flame is a move then the +hit pose; into the open exit a move then the jump; a teleport; a bomb is a crouch with no move; +a new level has no move. CONTRIBUTING.md now asks new animations to follow the pattern. Checked +in a browser: 70 ms after stepping into a blast the player is part-way there, walking; at 450 ms +they are in the hit pose on the flame tile. + +### 2026-10-23 — The Move button's key is M + +User request: the Move button's key hint said "WASD", which isn't the whole truth with +Diagonal; make it M, like B for Bomb. M now picks Move (then a direction or a tile), and the +button shows "M". W A S D and the arrows still step at once, as before. With Diagonal, Q / E / +Z / C now step diagonally at once too (they needed G first), or with Move picked. Checked in a +browser: the button shows M, M picks Move, and Z steps down-left in one turn. + +### 2026-10-23 — The auto-walk is twice as fast + +User request: auto-move "at least 50% faster". Once every enemy is gone, the walk to the +destination now takes a step every 0.5 s (`AUTO_STEP_SECONDS`, was 1 s) — twice as fast. A +step's slide takes 0.16 s, so each one still finishes before the next. Each step is still one +turn, and a Run or Teleport still counts as one move. diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png index d460d53..072fc4a 100644 Binary files a/docs/screenshots/almanac-charges.png and b/docs/screenshots/almanac-charges.png differ diff --git a/docs/screenshots/almanac-enemies.png b/docs/screenshots/almanac-enemies.png index 22de4c0..77dedb8 100644 Binary files a/docs/screenshots/almanac-enemies.png and b/docs/screenshots/almanac-enemies.png differ diff --git a/docs/screenshots/almanac.png b/docs/screenshots/almanac.png index f6f88a5..0e591e1 100644 Binary files a/docs/screenshots/almanac.png and b/docs/screenshots/almanac.png differ diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png index 37829e5..fd9041f 100644 Binary files a/docs/screenshots/auto-move.png and b/docs/screenshots/auto-move.png differ diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png index 8146a75..bea517f 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 0475bc3..41ea440 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 c5609b1..13a9562 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 0d4e780..cdda149 100644 Binary files a/docs/screenshots/death.png and b/docs/screenshots/death.png differ diff --git a/docs/screenshots/demo.png b/docs/screenshots/demo.png index 2b171e0..7c9d5a0 100644 Binary files a/docs/screenshots/demo.png and b/docs/screenshots/demo.png differ diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png index 83428b3..4b04e59 100644 Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ diff --git a/docs/screenshots/dev.png b/docs/screenshots/dev.png index bd1801e..2d19f2b 100644 Binary files a/docs/screenshots/dev.png and b/docs/screenshots/dev.png differ diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png index b6eadfa..a5ba5c2 100644 Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ diff --git a/docs/screenshots/gem-offer.png b/docs/screenshots/gem-offer.png index 1da850a..4b17d69 100644 Binary files a/docs/screenshots/gem-offer.png and b/docs/screenshots/gem-offer.png differ diff --git a/docs/screenshots/gem.png b/docs/screenshots/gem.png index f428aec..143b47c 100644 Binary files a/docs/screenshots/gem.png and b/docs/screenshots/gem.png differ diff --git a/docs/screenshots/info-enemy.png b/docs/screenshots/info-enemy.png index bafa33b..da2c0dc 100644 Binary files a/docs/screenshots/info-enemy.png and b/docs/screenshots/info-enemy.png differ diff --git a/docs/screenshots/info-hud.png b/docs/screenshots/info-hud.png index 2ac3c62..34ea3a6 100644 Binary files a/docs/screenshots/info-hud.png and b/docs/screenshots/info-hud.png differ diff --git a/docs/screenshots/info-mode.png b/docs/screenshots/info-mode.png index 3ba678f..023bd29 100644 Binary files a/docs/screenshots/info-mode.png and b/docs/screenshots/info-mode.png differ diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png index 43d66c1..8d03a9a 100644 Binary files a/docs/screenshots/lingering.png and b/docs/screenshots/lingering.png differ diff --git a/docs/screenshots/long-press.png b/docs/screenshots/long-press.png index 032bd2e..96d8794 100644 Binary files a/docs/screenshots/long-press.png and b/docs/screenshots/long-press.png differ diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png index 99d0710..299f47d 100644 Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ diff --git a/docs/screenshots/move-picked.png b/docs/screenshots/move-picked.png index f26d04a..07b8e88 100644 Binary files a/docs/screenshots/move-picked.png and b/docs/screenshots/move-picked.png differ diff --git a/docs/screenshots/new-run.png b/docs/screenshots/new-run.png index d303cff..a383c33 100644 Binary files a/docs/screenshots/new-run.png and b/docs/screenshots/new-run.png differ diff --git a/docs/screenshots/nuke.png b/docs/screenshots/nuke.png index 8beb80e..0fc6f6a 100644 Binary files a/docs/screenshots/nuke.png and b/docs/screenshots/nuke.png differ diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png deleted file mode 100644 index 19ebf69..0000000 Binary files a/docs/screenshots/scrolled.png and /dev/null differ diff --git a/docs/screenshots/small-desktop.png b/docs/screenshots/small-desktop.png deleted file mode 100644 index 35e6590..0000000 Binary files a/docs/screenshots/small-desktop.png and /dev/null differ diff --git a/docs/screenshots/small.png b/docs/screenshots/small.png deleted file mode 100644 index 6ae57e2..0000000 Binary files a/docs/screenshots/small.png and /dev/null differ diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png index 03d1e8f..28b18f5 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 4ff134e..9f4acbd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,8 +11,8 @@ import { time, vec2, } from 'littlejsengine' -import { BACKGROUND, drawBoard, FLAME_SECONDS, toTile, type Flame, type PlayerAnim } from './render/board' -import { clearEffects, newEffects, pruneEffects, startEffects } from './render/effects' +import { BACKGROUND, drawBoard, FLAME_SECONDS, toTile, type Flame } from './render/board' +import { clearEffects, newEffects, playerTransition, pruneEffects, startEffects, type PlayerMove, type PlayerPose } from './render/effects' import { updateCamera } from './render/camera' import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, TEXTURES } from './render/sprites' import { isAvailable, isValidTarget, ACTIONS } from './sim/actions' @@ -51,7 +51,8 @@ let inspecting = false let hover: Vec | null = null let flames: Flame[] = [] let facingLeft = false -let playerAnim: PlayerAnim = { kind: 'none', from: { x: 0, y: 0 }, at: 0 } +let playerMove: PlayerMove | null = null +let playerPose: PlayerPose = { kind: 'none', at: 0 } const enemySteps = new Map<number, { from: Vec; at: number }>() const fx = newEffects() let snapCamera = true @@ -63,8 +64,11 @@ let armed: Vec | null = null let pressTimer: number | undefined let pressTile: Vec | null = null const LONG_PRESS_MS = 450 -/** With every enemy gone, auto-move walks by itself: one step this long after the last action. */ -const AUTO_STEP_SECONDS = 1 +/** + * With every enemy gone, auto-move walks by itself: one step this long after the last action. + * Half a second, twice as fast as at first (user request, 2026-10-23: "at least 50% faster"). + */ +const AUTO_STEP_SECONDS = 0.5 let lastActionAt = -Infinity /** Kinds of action charge picked up this run: only the first of each kind opens a dialog. */ const chargesSeen = new Set<ChargeId>() @@ -78,7 +82,8 @@ function firstCharge(e: GameEvent): ChargeId | null { /** A fresh view of the board: no animations under way, nothing picked, the camera snapped. */ function resetView(): void { - playerAnim = { kind: 'none', from: { x: 0, y: 0 }, at: 0 } + playerMove = null + playerPose = { kind: 'none', at: 0 } enemySteps.clear() clearEffects(fx) selected = null @@ -102,7 +107,9 @@ const OPTIONS_KEY = 'explodorama.newRunOptions' function preferredOptions(): RunOptions { try { - return { ...DEFAULT_OPTIONS, ...(JSON.parse(localStorage.getItem(OPTIONS_KEY) ?? '{}') as Partial<RunOptions>) } + // Only the options that still exist; Immortal was saved as `god` before 2026-10-23. + const saved = JSON.parse(localStorage.getItem(OPTIONS_KEY) ?? '{}') as Partial<RunOptions> & { god?: boolean } + return { dev: !!saved.dev, immortal: !!(saved.immortal ?? saved.god) } } catch { return { ...DEFAULT_OPTIONS } } @@ -124,11 +131,11 @@ function rememberOptions(options: RunOptions): void { */ const SAVE_KEY = 'explodorama.run' -/** A test URL's options: `?small`, `?dev`, `?god`, `?crumble` (for screenshots and bug reports). */ +/** A test URL's options: `?dev`, `?immortal` (for screenshots and bug reports). */ function urlOptions(): RunOptions { - return { size: params.has('small') ? 'small' : 'normal', dev: params.has('dev'), god: params.has('god'), crumble: params.has('crumble') } + return { dev: params.has('dev'), immortal: params.has('immortal') } } -const testing = ['seed', 'level', 'charges', 'small', 'dev', 'god', 'crumble'].some((p) => params.has(p)) +const testing = ['seed', 'level', 'charges', 'dev', 'immortal'].some((p) => params.has(p)) function save(): void { if (testing) return @@ -198,27 +205,30 @@ function act(action: Action, auto = false): void { /** A turn played: the new state, and everything that animates it (also for the demo). */ function animateTurn(result: ReturnType<typeof step>, action: Action): void { - startEffects(fx, state, result.state, result.events, time) + const prev = state + startEffects(fx, prev, result.state, result.events, time) state = result.state - 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 - // A step, run or diagonal step slides; a teleport (further than a run) vanishes and reappears. - if (e.type === 'moved') { - const far = action.id === 'teleport' - playerAnim = { kind: far ? 'teleport' : 'walk', from: e.from, at: time } - } - if (e.type === 'bombPlaced' && playerAnim.kind !== 'walk') playerAnim = { kind: 'duck', from: state.player, at: time } - // Into the doorway first (the step's walk), then the jump (user request, 2026-10-23: the step - // onto the exit had no animation). - if (e.type === 'levelCleared') playerAnim = { kind: 'jump', from: playerAnim.kind === 'walk' ? playerAnim.from : state.player, at: time } - if (e.type === 'levelStarted' || e.type === 'levelRestarted') { - playerAnim = { kind: 'none', from: state.player, at: time } - enemySteps.clear() + // The player's move comes from their change of tile, the pose from what happened; the pose + // waits for the move, so nothing can skip it (effects.ts `playerTransition`). + const player = playerTransition(prev, state, result.events, action.id === 'teleport', time) + if (player.move) playerMove = player.move + if (player.move || player.pose.kind !== 'none') playerPose = player.pose + if (player.move && player.move.from.x !== state.player.x) facingLeft = state.player.x < player.move.from.x + const newLevel = result.events.some((e) => e.type === 'levelStarted' || e.type === 'levelRestarted') + if (newLevel) { + snapCamera = true + playerMove = null + playerPose = player.pose + enemySteps.clear() + } + if (newLevel || result.events.some((e) => e.type === 'levelCleared')) destination = null + for (const e of result.events) if (e.type === 'exploded') flames.push({ tiles: e.tiles, born: time }) + // Every enemy that changed tile steps from where it was: from the state, like the player. + if (!newLevel) { + for (const enemy of state.enemies) { + const was = prev.enemies.find((o) => o.id === enemy.id) + if (was && (was.pos.x !== enemy.pos.x || was.pos.y !== enemy.pos.y)) enemySteps.set(enemy.id, { from: was.pos, at: time }) } - if (e.type === 'enemyMoved') enemySteps.set(e.id, { from: e.from, at: time }) } } @@ -340,8 +350,9 @@ function showMenu(): void { } /** - * The new-run dialog: board size and the mode checkboxes (remembered for next time). From the - * menu, while a run is still going, it also warns that the run will be lost. + * The new-run dialog: the two mode checkboxes (remembered for next time). From the menu, while a + * run is still going, it also warns that the run will be lost. (A board size and Crumbling crates + * were choices too until 2026-10-23; user request.) */ function newRunDialog(): void { const o = preferredOptions() @@ -354,13 +365,9 @@ function newRunDialog(): void { title: 'New run', body: warning + - `<fieldset class="choices"><legend>Board</legend>` + - `<label><input type="radio" name="size" value="normal"${checked(o.size === 'normal')}><span><b>Normal</b> 31 × 13, scrolls sideways, 50 levels.</span></label>` + - `<label><input type="radio" name="size" value="small"${checked(o.size === 'small')}><span><b>Small</b> 9 × 11, fits the screen, 20 levels, fewer enemies and crates.</span></label>` + - `</fieldset><fieldset class="choices"><legend>Modes</legend>` + - `<label><input type="checkbox" name="dev"${checked(o.dev)}><span><b>Developer</b> An extra gem lies right next to you at the start of every level (the level’s own is still hidden).</span></label>` + - `<label><input type="checkbox" name="god"${checked(o.god)}><span><b>God</b> Nothing can kill you.</span></label>` + - `<label><input type="checkbox" name="crumble"${checked(o.crumble)}><span><b>Crumbling crates</b> Once the last enemy is gone, the crates crumble away over 1–3 turns.</span></label>` + + `<fieldset class="choices"><legend>Modes</legend>` + + `<label><input type="checkbox" name="dev"${checked(o.dev)}><span><b>Developer</b> An extra gem lies right next to you at the start of every level (the level’s own is still hidden). Achievements still count, marked with a red border.</span></label>` + + `<label><input type="checkbox" name="immortal"${checked(o.immortal)}><span><b>Immortal</b> Nothing can kill you. You can play through to the end, but an Immortal run earns no achievements.</span></label>` + `</fieldset>`, buttons: [ { label: 'Start', kind: ongoing ? 'danger' : 'primary', icon: newRunIcon, onClick: startChosenRun }, @@ -372,12 +379,7 @@ function newRunDialog(): void { function startChosenRun(): void { const form = document.getElementById('dialog-body')! const on = (name: string) => form.querySelector<HTMLInputElement>(`input[name=${name}]`)!.checked - const options: RunOptions = { - size: form.querySelector<HTMLInputElement>('input[name=size]:checked')?.value === 'small' ? 'small' : 'normal', - dev: on('dev'), - god: on('god'), - crumble: on('crumble'), - } + const options: RunOptions = { dev: on('dev'), immortal: on('immortal') } rememberOptions(options) startRun(randomSeed(), options) } @@ -466,17 +468,22 @@ const KEY_DIRS: Record<string, Dir> = { } const ACTION_KEYS: Record<string, ActionId> = { + // M picks Move, as B picks Bomb (user request, 2026-10-23; the button said "WASD"). + KeyM: 'move', KeyB: 'bomb', KeyR: 'run', KeyX: 'detonate', KeyK: 'kick', KeyT: 'throw', KeyG: 'diagonal', KeyP: 'teleport', } -/** With Diagonal picked: Q / E / Z / C step up-left, up-right, down-left, down-right. */ +/** Q / E / Z / C: a diagonal step up-left, up-right, down-left, down-right (with Diagonal). */ 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) => { // The dialog has the keyboard while it is open (Esc closes it, Tab moves between its buttons). if (e.repeat || dialogOpen()) return const d = KEY_DIRS[e.code] - const diag = selected === 'diagonal' ? DIAGONAL_KEYS[e.code] : undefined + // With Diagonal, Q / E / Z / C step diagonally as W A S D step straight: at once, or with Move + // (or Diagonal) picked. + const diagonalKeys = isAvailable(state, 'diagonal') && (!selected || selected === 'move' || selected === 'diagonal') + const diag = diagonalKeys ? 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 }) @@ -721,7 +728,7 @@ await engineInit( () => {}, guarded('draw', () => drawBoard({ - state, danger, selected, hover, flames, facingLeft, destination, path, armed, playerAnim, enemySteps, fx, + state, danger, selected, hover, flames, facingLeft, destination, path, armed, playerMove, playerPose, enemySteps, fx, pathBlocked: !!path?.[0] && !safeStep(path[0]), }), ), diff --git a/src/render/board.ts b/src/render/board.ts index f9036be..fbcb724 100644 --- a/src/render/board.ts +++ b/src/render/board.ts @@ -6,7 +6,10 @@ import { blastTiles } from '../sim/explosions' import { dirBetween } from '../sim/enemies' import { cellAt, cloneState, eq, exitOpen, idx } from '../sim/state' import type { ActionId, Bomb, Enemy, GameState, Vec } from '../sim/types' -import { appearScale, dropSprite, frostCrack, frostLevel, GONE_SECONDS, key, progress, pulseScale, WALK_SECONDS, type Effects, type Gone } from './effects' +import { + appearScale, dropSprite, frostCrack, frostLevel, GONE_SECONDS, key, moveSeconds, progress, pulseScale, TELEPORT_SECONDS, WALK_SECONDS, + type Effects, type Gone, type PlayerMove, type PlayerPose, +} from './effects' import { CHARGE_ICONS, digitSprite, ENEMY_SPRITES, GEM_SPRITES, sprite, wallSprite, type BoardSprite } from './sprites' /** @@ -43,8 +46,9 @@ export interface View { pathBlocked: boolean /** A tile a long press has armed for a bomb. */ armed: Vec | null - /** The player's latest animation (main.ts starts one for a step, a bomb, a cleared level). */ - playerAnim: PlayerAnim + /** The player's latest move (from their change of tile) and pose, drawn move first (effects.ts). */ + playerMove: PlayerMove | null + playerPose: PlayerPose /** Each enemy's latest step, by id: where from, and when (LittleJS `time`). */ enemySteps: ReadonlyMap<number, { from: Vec; at: number }> /** What the last turns changed, still playing out (render/effects.ts). */ @@ -56,17 +60,9 @@ export interface View { * bomb). jump: jumps for joy (level cleared), until the next level. teleport: shrinks away at * `from` and grows back at the new tile. none: idle. */ -export interface PlayerAnim { - kind: 'none' | 'walk' | 'duck' | 'jump' | 'teleport' - from: Vec - /** LittleJS `time` when it started. */ - at: number -} - /** Seconds a crouch takes (a step's slide: WALK_SECONDS, effects.ts). */ const DUCK_SECONDS = 0.22 /** Each half of a teleport: vanishing, then appearing. */ -const TELEPORT_SECONDS = 0.15 export const FLAME_SECONDS = 0.45 @@ -221,53 +217,56 @@ export function drawBoard(view: View): void { // The Shield's ring and a Star's pulsing gold ring go where the player is drawn, so they walk // with them (user request, 2026-10-23: they jumped to the new tile). The Shield's stays inside // the player's tile. - const at = playerPos(s, view.playerAnim) + const at = playerPos(s, view.playerMove) if (s.shield) drawCircle(at, 0.88 * appearScale(fx, key.shield, time), C.clear, 0.06, C.shield) if (s.star > 0) drawCircle(at, 1.0 + 0.06 * Math.sin(time * 8), C.clear, 0.08, C.star) if (after && !eq(after.player, s.player)) { drawTile(toWorld(s, after.player), tileSize, sprite('character_green_idle'), C.ghost, 0, view.facingLeft) } - drawPlayer(s, view.playerAnim, view.facingLeft) + drawPlayer(s, view.playerMove, view.playerPose, view.facingLeft) } /** Where the player is on screen right now: sliding between tiles while walking, otherwise on their tile. */ -function playerPos(s: GameState, anim: PlayerAnim): Vector2 { +function playerPos(s: GameState, move: PlayerMove | null): Vector2 { const to = toWorld(s, s.player) - const t = time - anim.at - if ((anim.kind === 'walk' || anim.kind === 'jump') && t < WALK_SECONDS) return toWorld(s, anim.from).lerp(to, t / WALK_SECONDS) - if (anim.kind === 'teleport' && t < TELEPORT_SECONDS) return toWorld(s, anim.from) - return to + const t = move ? time - move.at : Infinity + if (!move || t >= moveSeconds(move)) return to + if (move.teleport) return t < TELEPORT_SECONDS ? toWorld(s, move.from) : to + return toWorld(s, move.from).lerp(to, t / WALK_SECONDS) } /** - * The player, animated like the enemies: an idle bob with a glance at the camera now and then, - * walking frames while sliding into a new tile, a crouch when placing a bomb, a jump when the - * level is cleared, the hit pose when caught. + * The player: first their move this turn — a hop from tile to tile with walking frames, or for a + * teleport shrinking away and growing back — then, once it is done, their pose: the hit pose when + * caught, a jump when the level is cleared, a crouch after placing a bomb; otherwise an idle bob + * with a glance at the camera now and then. Nothing can skip the move (user request, 2026-10-23). */ -function drawPlayer(s: GameState, anim: PlayerAnim, facingLeft: boolean): void { +function drawPlayer(s: GameState, move: PlayerMove | null, pose: PlayerPose, facingLeft: boolean): void { const to = toWorld(s, s.player) - const t = time - anim.at + const t = move ? time - move.at : Infinity + if (move && t < moveSeconds(move)) { + if (move.teleport) { + const out = t < TELEPORT_SECONDS + const k = out ? 1 - t / TELEPORT_SECONDS : (t - TELEPORT_SECONDS) / TELEPORT_SECONDS + drawTile(out ? toWorld(s, move.from) : to, vec2(k), sprite('character_green_front'), C.white, (1 - k) * 2, facingLeft) + return + } + const k = t / WALK_SECONDS + const pos = toWorld(s, move.from).lerp(to, k).add(vec2(0, 0.08 * Math.sin(k * Math.PI))) + drawTile(pos, vec2(1), sprite(k < 0.5 ? 'character_green_walk_a' : 'character_green_walk_b'), C.white, 0, facingLeft) + return + } + // The pose starts once the move is done. + const since = time - Math.max(pose.at, move ? move.at + moveSeconds(move) : pose.at) let pos = to let frame: BoardSprite = 'character_green_idle' - if (s.status === 'dead' || s.status === 'gameOver') { + if (pose.kind === 'hit' || s.status === 'dead' || s.status === 'gameOver') { frame = 'character_green_hit' - pos = to.add(vec2(0.03 * Math.sin(time * 40) * Math.max(0, 1 - t * 2), 0)) - } else if (anim.kind === 'teleport' && t < 2 * TELEPORT_SECONDS) { - // Shrinks away where it was, then grows back where it lands. - const out = t < TELEPORT_SECONDS - const k = out ? 1 - t / TELEPORT_SECONDS : (t - TELEPORT_SECONDS) / TELEPORT_SECONDS - drawTile(out ? toWorld(s, anim.from) : to, vec2(k), sprite('character_green_front'), C.white, (1 - k) * 2, facingLeft) - return - } else if (anim.kind === 'jump' && (t >= WALK_SECONDS || eq(anim.from, s.player))) { - // Cleared: jumping for joy, once the step into the doorway (below) is done. + pos = to.add(vec2(0.03 * Math.sin(time * 40) * Math.max(0, 1 - since * 2), 0)) + } else if (pose.kind === 'jump') { frame = 'character_green_jump' - pos = to.add(vec2(0, 0.18 * Math.abs(Math.sin((t - WALK_SECONDS) * 6)))) - } else if ((anim.kind === 'walk' || anim.kind === 'jump') && t < WALK_SECONDS) { - const k = t / WALK_SECONDS - const from = toWorld(s, anim.from) - pos = from.lerp(to, k).add(vec2(0, 0.08 * Math.sin(k * Math.PI))) - frame = k < 0.5 ? 'character_green_walk_a' : 'character_green_walk_b' - } else if (anim.kind === 'duck' && t < DUCK_SECONDS) { + pos = to.add(vec2(0, 0.18 * Math.abs(Math.sin(since * 6)))) + } else if (pose.kind === 'duck' && since < DUCK_SECONDS) { frame = 'character_green_duck' } else { // Idle: a slow bob, and every 4 seconds a short look at the camera. diff --git a/src/render/camera.ts b/src/render/camera.ts index 6a1b9a5..1f7bd43 100644 --- a/src/render/camera.ts +++ b/src/render/camera.ts @@ -10,16 +10,16 @@ export interface Insets { let x: number | undefined /** - * All rows always fit between the HUD and the action bar. Horizontally the camera follows the - * player, eased, and stops at the level's edges, as in the original (PLAN.md §11). A small - * board is shown whole instead: its columns fit too, so there is no scrolling. + * The whole board fits between the HUD and the action bar: rows and columns (one board size + * since 2026-10-23, so there is no scrolling). If a screen were ever too narrow for it, the + * camera would follow the player sideways, eased, stopping at the level's edges. * `snap` jumps straight there, for the first frame of a level. */ export function updateCamera(s: GameState, insets: Insets, snap = false): void { const width = mainCanvasSize.x const height = mainCanvasSize.y const available = Math.max(1, height - insets.top - insets.bottom) - const scale = s.options.size === 'small' ? Math.min(available / s.height, width / s.width) : available / s.height + const scale = Math.min(available / s.height, width / s.width) setCameraScale(scale) const halfView = width / 2 / scale diff --git a/src/render/effects.ts b/src/render/effects.ts index b68e87b..e76ee92 100644 --- a/src/render/effects.ts +++ b/src/render/effects.ts @@ -127,9 +127,9 @@ export function startEffects(fx: Effects, prev: GameState, next: GameState, even case 'enemyDied': { const enemy = prev.enemies.find((o) => o.id === e.id) // Walked into the flame this turn: it finishes that step first, then dies (user report, - // 2026-10-23: it died on the new tile without ever leaving the old one). - const moved = events.find((m) => m.type === 'enemyMoved' && m.id === e.id) - const from = moved?.type === 'enemyMoved' ? moved.from : undefined + // 2026-10-23: it died on the new tile without ever leaving the old one). From its change + // of tile, like every move. + const from = enemy && (enemy.pos.x !== e.pos.x || enemy.pos.y !== e.pos.y) ? enemy.pos : undefined if (enemy) fx.gone.push({ kind: 'enemy', enemy: { ...enemy, pos: e.pos }, from, at: from ? now + WALK_SECONDS : now }) break } @@ -238,3 +238,56 @@ export function pulseScale(fx: Effects, k: string, now: number, amount = 0.15): export function progress(at: number, seconds: number, now: number): number { return Math.min(1, Math.max(0, (now - at) / seconds)) } + +/** + * The player's movement this turn, worked out from the state itself — their tile before and + * after — so nothing else that happens in the same turn (a death, the level cleared, a bomb) can + * skip it (user request, 2026-10-23, after several moves that weren't animated: into flame, into + * the doorway). Drawn as a slide, or for a teleport as vanishing and reappearing. + */ +export interface PlayerMove { + from: Vec + at: number + teleport: boolean +} + +/** + * What the player does on top of moving, from the time `at` (and only once the move is done): + * crouch to place a bomb, jump when the level is cleared, the hit pose when caught. + */ +export interface PlayerPose { + kind: 'none' | 'duck' | 'jump' | 'hit' + at: number +} + +/** How long the player's move takes on screen: a step's slide, or a teleport's vanish and reappear. */ +export function moveSeconds(move: PlayerMove): number { + return move.teleport ? 2 * TELEPORT_SECONDS : WALK_SECONDS +} + +/** Seconds a teleport's vanishing (and then its reappearing) takes. */ +export const TELEPORT_SECONDS = 0.15 + +/** + * The player's move and pose for a turn from `prev` to `next` with its events. A new level or a + * retry starts still (the player is put there, not walking). + */ +export function playerTransition( + prev: GameState, + next: GameState, + events: GameEvent[], + teleported: boolean, + now: number, +): { move: PlayerMove | null; pose: PlayerPose } { + if (events.some((e) => e.type === 'levelStarted' || e.type === 'levelRestarted')) return { move: null, pose: { kind: 'none', at: now } } + const moved = prev.player.x !== next.player.x || prev.player.y !== next.player.y + const move = moved ? { from: { ...prev.player }, at: now, teleport: teleported } : null + const kind = events.some((e) => e.type === 'playerDied') + ? 'hit' + : events.some((e) => e.type === 'levelCleared') + ? 'jump' + : events.some((e) => e.type === 'bombPlaced') + ? 'duck' + : 'none' + return { move, pose: { kind, at: now } } +} diff --git a/src/sim/demo.ts b/src/sim/demo.ts index fdfa01c..a4e7581 100644 --- a/src/sim/demo.ts +++ b/src/sim/demo.ts @@ -382,7 +382,7 @@ function makeRoom(kind: SceneKind, rng: Rng): GameState { // Crates on about one free tile in six, never next to the player (room to place a bomb and go). for (const p of tiles) if (steps(p, player) > 2 && rng.next() < 0.16) cells[p.y * WIDTH + p.x] = 'block' const s: GameState = { - seed: rng.int(2 ** 31), options: { ...DEFAULT_OPTIONS, size: 'small' }, level: 1, turn: 0, width: WIDTH, height: HEIGHT, + seed: rng.int(2 ** 31), options: { ...DEFAULT_OPTIONS }, level: 1, turn: 0, width: WIDTH, height: HEIGHT, cells, player: { ...player }, lives: START_LIVES, stats: { ...START_STATS }, bombs: [], status: 'playing', enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 99, coins: 0, boosts: noBoosts(), shield: false, frozen: 0, decoy: null, burning: [], diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts index ec50b80..4fe4b9a 100644 --- a/src/sim/levelgen.ts +++ b/src/sim/levelgen.ts @@ -1,14 +1,20 @@ import { stream } from './rng' -import { DIR_LIST, type BoardSize, type Cell, type Dir, type EnemyKind, type GemColor, type PowerUp, type Vec } from './types' +import { DIR_LIST, type Cell, type Dir, type EnemyKind, type GemColor, type PowerUp, type Vec } from './types' -/** The NES layout: 31 × 13 with a wall all round and a pillar at every even (x, y). */ -export const LEVEL_WIDTH = 31 -export const LEVEL_HEIGHT = 13 +/** + * The board: 9 × 11 with a wall all round and a pillar at every even (x, y), fitting a phone + * screen whole. The only size since 2026-10-23 (user request); until then this was the "small" + * board, beside the NES's 31 × 13 with 50 levels. + */ +export const LEVEL_WIDTH = 9 +export const LEVEL_HEIGHT = 11 export const START: Vec = { x: 1, y: 1 } /** Share of the open tiles that get a soft block. */ -export const BLOCK_DENSITY = 0.35 +export const BLOCK_DENSITY = 0.25 /** No enemy starts this close (Manhattan distance) to the player, and the exit isn't this close either. */ export const SAFE_DISTANCE = 4 +/** The run's last level; clearing it wins (PLAN.md §11). */ +export const LAST_LEVEL = 20 /** * Which enemy types a level can have (PLAN.md §10): Blobs only at first, then one new type @@ -19,47 +25,14 @@ export function enemyKinds(level: number): EnemyKind[] { return all.slice(0, 1 + Math.floor((level - 1) / 6)) } -/** What differs between board sizes. To tune by playtesting. */ -interface SizeRules { - width: number - height: number - /** Share of the open tiles that get a soft block. */ - density: number - /** The run's last level; clearing it wins (PLAN.md §11). */ - lastLevel: number - enemies(level: number): number - /** The turn budget for the par bonus. */ - par(enemies: number): number -} - -export const SIZES: Record<BoardSize, SizeRules> = { - // 3 enemies on level 1, one more every 3 levels, at most 10; par 50 + 10 per enemy. - normal: { - width: LEVEL_WIDTH, height: LEVEL_HEIGHT, density: BLOCK_DENSITY, lastLevel: 50, - enemies: (level) => Math.min(10, 3 + Math.floor((level - 1) / 3)), - par: (enemies) => 50 + 10 * enemies, - }, - // Fits a phone screen whole. 2 enemies on level 1, one more every 4 levels, at most 5. - small: { - width: 9, height: 11, density: 0.25, lastLevel: 20, - enemies: (level) => Math.min(5, 2 + Math.floor((level - 1) / 4)), - par: (enemies) => 25 + 8 * enemies, - }, +/** 2 enemies on level 1, one more every 4 levels, at most 5. To tune by playtesting. */ +export function enemyCount(level: number): number { + return Math.min(5, 2 + Math.floor((level - 1) / 4)) } -export function enemyCount(level: number, size: BoardSize = 'normal'): number { - return SIZES[size].enemies(level) -} - -export function parFor(enemies: number, size: BoardSize = 'normal'): number { - return SIZES[size].par(enemies) -} - -/** The normal run's last level. */ -export const LAST_LEVEL = SIZES.normal.lastLevel - -export function lastLevel(size: BoardSize): number { - return SIZES[size].lastLevel +/** The turn budget for the par bonus. */ +export function parFor(enemies: number): number { + return 25 + 8 * enemies } /** A gem's colour is only how it looks. */ @@ -84,8 +57,8 @@ export interface Layout { par: number } -export function generateLevel(seed: number, level: number, size: BoardSize = 'normal'): Layout { - const { width, height, density } = SIZES[size] +export function generateLevel(seed: number, level: number): Layout { + const [width, height, density] = [LEVEL_WIDTH, LEVEL_HEIGHT, BLOCK_DENSITY] const rng = stream(seed, 'levelgen', level) const cells: Cell[] = [] for (let y = 0; y < height; y++) { @@ -119,14 +92,14 @@ export function generateLevel(seed: number, level: number, size: BoardSize = 'no const open = cells.flatMap((c, i) => (c === 'floor' && far(i) ? [i] : [])) const kinds = enemyKinds(level) const enemies: EnemyStart[] = [] - for (let n = enemyCount(level, size); n > 0 && open.length; n--) { + for (let n = enemyCount(level); n > 0 && open.length; n--) { const i = open.splice(rng.int(open.length), 1)[0]! enemies.push({ kind: kinds[rng.int(kinds.length)]!, pos: at(i), dir: DIR_LIST[rng.int(4)]! }) } // Any other block, near the start or not, so an early gem is possible. const others = cells.flatMap((c, i) => (c === 'block' && !(i % width === exit.x && Math.floor(i / width) === exit.y) ? [i] : [])) const powerUp = { pos: at(others[rng.int(others.length)]!), color: GEM_COLORS[rng.int(GEM_COLORS.length)]! } - return { width, height, cells, start: { ...START }, exit, powerUp, enemies, par: parFor(enemies.length, size) } + return { width, height, cells, start: { ...START }, exit, powerUp, enemies, par: parFor(enemies.length) } } function key(p: Vec): string { diff --git a/src/sim/record.ts b/src/sim/record.ts index 0ba0b39..b4dfd71 100644 --- a/src/sim/record.ts +++ b/src/sim/record.ts @@ -6,7 +6,7 @@ import { DEFAULT_OPTIONS, type Action, type GameEvent, type GameState, type RunO * Bumped whenever a rule change would make an old record replay differently. Records from * another version can't be trusted to replay (PLAN.md §11). */ -export const SIM_VERSION = 19 +export const SIM_VERSION = 20 /** 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 521b1aa..ff67e0f 100644 --- a/src/sim/state.ts +++ b/src/sim/state.ts @@ -50,7 +50,7 @@ export function newRun(seed: number, level = 1, n = 0, options: RunOptions = DEF * is the same board. What the player has comes with them (`carryOf` decides what a death costs). */ export function createLevel(seed: number, level: number, options: RunOptions, carry: Carry): GameState { - const { width, height, cells, start, exit, powerUp, enemies, par } = generateLevel(seed, level, options.size) + const { width, height, cells, start, exit, powerUp, enemies, par } = generateLevel(seed, level) const s: GameState = { seed, options: { ...options }, level, turn: 0, width, height, cells, player: start, lives: carry.lives, stats: { ...carry.stats }, coins: carry.coins, boosts: { ...carry.boosts }, @@ -136,9 +136,9 @@ export function playerCanPass(s: GameState, p: Vec): boolean { return (cell === 'floor' || (cell === 'block' && s.powers.wallPass)) && (!bombAt(s, p) || s.powers.bombPass) } -/** Flame can't hurt the player this turn: Flame Pass, a Star, or God mode. */ +/** Flame can't hurt the player this turn: Flame Pass, a Star, or Immortal. */ export function flameSafe(s: GameState): boolean { - return s.powers.flamePass || s.star > 0 || s.options.god + return s.powers.flamePass || s.star > 0 || s.options.immortal } /** diff --git a/src/sim/turn.ts b/src/sim/turn.ts index 45d7314..5e99b17 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -1,6 +1,6 @@ import { ACTIONS, applyCharge, isAvailable, isValidTarget } from './actions' import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies' -import { enemyKinds, lastLevel } from './levelgen' +import { enemyKinds, LAST_LEVEL } from './levelgen' import { detonate, tickBombs } from './explosions' import { add, bombAt, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, PAR_BONUS } from './state' import { stream } from './rng' @@ -67,7 +67,7 @@ export function step(prev: GameState, action: Action): StepResult { s.turn++ s.runTurns++ events.push({ type: 'levelCleared', level: s.level, turns: s.turn, parBonus }) - if (s.level >= lastLevel(s.options.size)) { + if (s.level >= LAST_LEVEL) { s.status = 'won' events.push({ type: 'runWon', turns: s.runTurns, deaths: s.deaths }) } else s.status = 'cleared' @@ -78,7 +78,7 @@ export function step(prev: GameState, action: Action): StepResult { // Flame Pass stops flame. let shielded = false const hurt = (cause: 'flame' | 'enemy'): void => { - if (s.options.god || shielded || s.star > 0 || (cause === 'flame' && s.powers.flamePass)) return + if (s.options.immortal || shielded || s.star > 0 || (cause === 'flame' && s.powers.flamePass)) return if (s.shield) { s.shield = false shielded = true @@ -150,7 +150,7 @@ export function step(prev: GameState, action: Action): StepResult { } if (enemiesBefore > 0 && s.enemies.length === 0) { events.push({ type: 'exitOpened', pos: s.exit }) - if (s.options.crumble) startCrumbling(s, events) + startCrumbling(s, events) } // This turn's flame lingers through the next turn; last turn's goes out. @@ -207,7 +207,10 @@ function dropSpot(s: GameState, from: Vec): Vec { return { ...from } } -/** The crumble option: once the last enemy is gone, every crate gets 1–3 turns before it goes. */ +/** + * Once the last enemy is gone, every crate gets 1–3 turns before it goes, revealing the exit and + * the gem. Always, since 2026-10-23 (user request: it was the Crumbling crates option). + */ function startCrumbling(s: GameState, events: GameEvent[]): void { if (s.crumbling.length) return const rng = stream(s.seed, 'crumble', s.level) diff --git a/src/sim/types.ts b/src/sim/types.ts index dd53fc3..1961b4a 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -54,22 +54,22 @@ export interface Stats { */ export type Status = 'playing' | 'dead' | 'gameOver' | 'cleared' | 'won' -/** The board size a run is played on (user request, 2026-10-21). */ -export type BoardSize = 'normal' | 'small' - -/** Chosen when a run starts, fixed for the run, part of its record. */ +/** + * Chosen when a run starts, fixed for the run, part of its record. (A board size and Crumbling + * crates were options too until 2026-10-23: now there is one board, and crates always crumble.) + */ export interface RunOptions { - /** normal: 31 × 13, 50 levels. small: 9 × 11, 20 levels, fewer enemies and crates. */ - size: BoardSize /** Developer mode: an extra gem lies right of the start, to try the choice at once; the level keeps its own. Nothing else. */ dev: boolean - /** God mode: nothing can kill the player. */ - god: boolean - /** Once the last enemy dies, every crate crumbles away over 1–3 turns. */ - crumble: boolean + /** + * Immortal (called God until 2026-10-23, user request): nothing can kill the player, so the run + * can be played to the end — but it earns no achievements (when they exist: the victory + * certificate, the Workshop's rewards). + */ + immortal: boolean } -export const DEFAULT_OPTIONS: RunOptions = { size: 'normal', dev: false, god: false, crumble: false } +export const DEFAULT_OPTIONS: RunOptions = { dev: false, immortal: false } /** The power-ups (PLAN.md §5, after the NES eight). Taking a level's gem offers a choice of them. */ export type PowerUpKind = @@ -219,7 +219,7 @@ export interface GameState { * a bomb there. It doesn't burn crates, drops or bombs a second time. */ burning: number[] - /** Crumbling crates (the crumble option): tile index and turns until it is gone. */ + /** Crumbling crates (once the last enemy is gone): tile index and turns until it is gone. */ crumbling: { cell: number; turns: number }[] decoy: Decoy | null /** The AI and drop random streams' positions (`Rng.state`), so a turn is a pure function of the state. */ diff --git a/src/style.css b/src/style.css index 77dc3f7..a270f1e 100644 --- a/src/style.css +++ b/src/style.css @@ -286,6 +286,11 @@ img { display: none; } +/* A Developer run's victory (controls.ts): framed in red, as its achievements will be. */ +#banner.developer { + box-shadow: inset 0 0 0 8px #e8253f; +} + /* Animations for the HTML parts: the banner comes in after a short pause, so the death or the level-cleared jump is seen first; dialogs pop up over a fading backdrop. */ @keyframes fade-in { @@ -741,10 +746,12 @@ body.demo #banner { display: none; } +/* The title in the rounded system font, extra bold: Kenney Future's blocky letters (an X like an + H) were hard to read this big (user request, 2026-10-23). */ #demo h1 { margin: 0 0 6px; - font: 24px/1.2 'Kenney Future', ui-rounded, system-ui, sans-serif; - letter-spacing: 0.04em; + font: 800 30px/1.15 ui-rounded, 'SF Pro Rounded', system-ui, -apple-system, sans-serif; + letter-spacing: 0.01em; } /* The words, with a scene's icon beside them (not in the line, which would space it unevenly). */ diff --git a/src/ui/controls.ts b/src/ui/controls.ts index 33c6c8a..0c8095f 100644 --- a/src/ui/controls.ts +++ b/src/ui/controls.ts @@ -37,7 +37,7 @@ interface ButtonDef { } const BUTTONS: ButtonDef[] = [ - { id: 'move', label: 'Move', icon: 'icon_move', key: 'WASD', group: ['move', 'run', 'teleport', 'kick', 'diagonal'] }, + { id: 'move', label: 'Move', icon: 'icon_move', key: 'M', group: ['move', 'run', 'teleport', 'kick', 'diagonal'] }, { id: 'bomb', label: 'Bomb', icon: 'bomb', key: 'B', group: ['bomb', 'throw', 'detonate'] }, { id: 'wait', label: 'Wait', icon: 'icon_wait', key: 'Space', group: ['wait'] }, ] @@ -171,13 +171,8 @@ export function createControls(handlers: Handlers) { const hearts = Array.from({ length: Math.max(s.lives, START_LIVES) }, (_, i) => icon(i < s.lives ? 'hud_heart' : 'hud_heart_empty'), ) - // The run's settings, as small tags, when it isn't a plain normal run. - const tags = [ - s.options.size === 'small' && 'Small', - s.options.dev && 'Dev', - s.options.god && 'God', - s.options.crumble && 'Crumble', - ].filter(Boolean) + // The run's modes, as small tags, when it has any. + const tags = [s.options.dev && 'Dev', s.options.immortal && 'Immortal'].filter(Boolean) show(hud.modes, tags.length ? item('modes', tags.map((t) => `<span class="tag">${t}</span>`).join('')) : '') show(hud.lives, item('lives', `<span class="sr-only">${s.lives} lives</span>${hearts.join('')}`)) show(hud.level, item('level', icon('flag_green_a') + number(s.level, 'Level '))) @@ -224,10 +219,15 @@ export function createControls(handlers: Handlers) { bannerButton.textContent = 'Next level' bannerIcon.src = spriteUrl('flag_green_a') } else if (s.status === 'won') { - bannerText.textContent = `You cleared all ${s.level} levels! ${s.runTurns} turns, ${s.deaths} ${s.deaths === 1 ? 'life' : 'lives'} lost. Seed ${s.seed}.` + bannerText.textContent = + `You cleared all ${s.level} levels! ${s.runTurns} ${s.runTurns === 1 ? 'turn' : 'turns'}, ${s.deaths} ${s.deaths === 1 ? 'life' : 'lives'} lost. Seed ${s.seed}.` + + (s.options.dev ? ' (Developer run)' : '') bannerButton.textContent = 'New run' bannerIcon.src = spriteUrl('star') } + // A Developer run's achievements count, but carry a red border, like the red quarters some + // bars kept for their own plays on the jukebox (user request, 2026-10-23). + banner.classList.toggle('developer', s.status === 'won' && s.options.dev) }, /** True if the banner is showing; Enter presses its button. */ pressBanner(): boolean { diff --git a/src/ui/info.ts b/src/ui/info.ts index 3733958..0ad363c 100644 --- a/src/ui/info.ts +++ b/src/ui/info.ts @@ -1,5 +1,5 @@ import { BOMB_BOOST, DECOY_TURNS, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE } from '../sim/actions' -import { lastLevel } from '../sim/levelgen' +import { LAST_LEVEL } from '../sim/levelgen' import { bombAt, cellAt, eq, exitOpen, isBurning, PAR_BONUS } from '../sim/state' import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS } from '../sim/turn' import type { ActionId, ChargeId, Dir, EnemyKind, GameState, Powers, PowerUpKind, Vec } from '../sim/types' @@ -77,12 +77,11 @@ const PUNISH = `Don’t blast it once it’s uncovered: flame on it lets out ${E /** The top bar's items. */ export type HudItem = 'modes' | 'lives' | 'level' | 'enemies' | 'coins' | 'turn' | 'bombs' | 'fire' | 'fuse' | 'star' | keyof Powers | ChargeId -/** One line per setting of the run that differs from a plain normal run. */ +/** One line per mode the run has on (none in a plain run). */ export function modeLines(s: GameState): string[] { - const lines = [s.options.size === 'small' ? `Small board: ${s.width} × ${s.height}, ${lastLevel('small')} levels, fewer enemies and crates.` : `Normal board: ${s.width} × ${s.height}, ${lastLevel('normal')} levels.`] - if (s.options.dev) lines.push('Developer mode: an extra gem lies right next to you at the start of every level. The level’s own gem is still hidden under a crate.') - if (s.options.god) lines.push('God mode: nothing can kill you.') - if (s.options.crumble) lines.push('Crumbling crates: once the last enemy is gone, every crate crumbles away over 1–3 turns.') + const lines: string[] = [] + if (s.options.dev) lines.push('Developer mode: an extra gem lies right next to you at the start of every level. The level’s own gem is still hidden under a crate. Achievements still count, marked with a red border.') + if (s.options.immortal) lines.push('Immortal: nothing can kill you. You can play through to the end, but the run earns no achievements.') return lines } @@ -96,10 +95,10 @@ export function hudInfo(item: HudItem, s: GameState): Info { case 'lives': return { icon: 'hud_heart', - title: s.options.god ? `Lives: ${s.lives} (God mode)` : `Lives: ${s.lives}`, + title: s.options.immortal ? `Lives: ${s.lives} (Immortal)` : `Lives: ${s.lives}`, body: [ 'Caught by flame or touched by an enemy, you lose a life and the level starts again, same layout and enemies.', - s.options.god ? 'In God mode nothing can kill you, so you never lose one.' : 'With no lives left, the run is over.', + s.options.immortal ? 'Immortal: nothing can kill you, so you never lose one.' : 'With no lives left, the run is over.', ], } case 'modes': @@ -107,8 +106,8 @@ export function hudInfo(item: HudItem, s: GameState): Info { case 'level': return { icon: 'flag_green_a', - title: `Level ${s.level} of ${lastLevel(s.options.size)}`, - body: [`Clear all ${lastLevel(s.options.size)} levels to win the run. New enemy types join every few levels.`], + title: `Level ${s.level} of ${LAST_LEVEL}`, + body: [`Clear all ${LAST_LEVEL} levels to win the run. New enemy types join every few levels.`], } case 'enemies': return { diff --git a/test/animation.test.ts b/test/animation.test.ts new file mode 100644 index 0000000..5f2b01f --- /dev/null +++ b/test/animation.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { playerTransition } from '../src/render/effects' +import { step } from '../src/sim/turn' +import { board, v } from './helpers' + +/** + * Every move of the player is animated, whatever else the turn brings (user request, 2026-10-23, + * after moves into flame and into the doorway weren't): the move comes from the change of tile. + */ +describe('the player always moves on screen', () => { + it('a step into flame: the move, then the hit pose', () => { + const s = board(` + ###### + #P.1.# + ######`) + const r = step(s, { id: 'move', target: v(2, 1) }) + expect(r.state.status).toBe('dead') + const t = playerTransition(s, r.state, r.events, false, 10) + expect(t.move).toEqual({ from: v(1, 1), at: 10, teleport: false }) + expect(t.pose.kind).toBe('hit') + }) + + it('a step into the open exit: the move, then the jump', () => { + const s = board(` + #### + #PE# + ####`) + const r = step(s, { id: 'move', target: v(2, 1) }) + const t = playerTransition(s, r.state, r.events, false, 10) + expect(t.move?.from).toEqual(v(1, 1)) + expect(t.pose.kind).toBe('jump') + }) + + it('a teleport vanishes and reappears; a bomb is a crouch, with no move', () => { + const s = board(` + ###### + #P...# + ######`) + s.powers.teleport = true + const tele = step(s, { id: 'teleport', target: v(4, 1) }) + expect(playerTransition(s, tele.state, tele.events, true, 10).move).toEqual({ from: v(1, 1), at: 10, teleport: true }) + const bomb = step(s, { id: 'bomb', target: v(2, 1) }) + const t = playerTransition(s, bomb.state, bomb.events, false, 10) + expect(t.move).toBeNull() + expect(t.pose.kind).toBe('duck') + }) + + it('a new level or a retry starts still', () => { + const s = board(` + #### + #PE# + ####`) + const cleared = step(s, { id: 'move', target: v(2, 1) }).state + const next = step(cleared, { id: 'continue' }) + expect(playerTransition(cleared, next.state, next.events, false, 10).move).toBeNull() + }) +}) diff --git a/test/danger.test.ts b/test/danger.test.ts index 68bfa84..044a6d5 100644 --- a/test/danger.test.ts +++ b/test/danger.test.ts @@ -10,6 +10,8 @@ import { Rng } from '../src/sim/rng' /** A random but legal action; `continue` when dead or the level is cleared. */ function randomAction(s: GameState, rng: Rng): Action { if (s.status === 'dead' || s.status === 'cleared') return { id: 'continue' } + // A gem taken: its choice comes first. + if (s.offer) return { id: 'choose', kind: s.offer[0]! } const ids = ACTION_ORDER.filter((id) => isAvailable(s, id)) const id = ids[rng.int(ids.length)]! const targets = ACTIONS[id].validTargets(s) @@ -29,7 +31,7 @@ describe('danger overlay', () => { for (let seed = 1; seed <= 20; seed++) { let s = newRun(seed, 1 + (seed % 20), 1) for (let t = 0; t < 150 && s.status !== 'gameOver'; t++) { - if (s.status === 'playing') { + if (s.status === 'playing' && !s.offer) { const danger = predictDanger(s) expect(danger.now).toEqual(burnedBy(s)) const waited = step(s, { id: 'wait' }).state diff --git a/test/options.test.ts b/test/options.test.ts index c6418d8..4037fba 100644 --- a/test/options.test.ts +++ b/test/options.test.ts @@ -1,24 +1,22 @@ import { describe, expect, it } from 'vitest' -import { enemyCount, generateLevel, lastLevel, SIZES, START } from '../src/sim/levelgen' +import { enemyCount, generateLevel, LAST_LEVEL, START } from '../src/sim/levelgen' import { newRecord, replay } from '../src/sim/record' import { newRun } from '../src/sim/state' import { drawOffer, step } from '../src/sim/turn' import { DEFAULT_OPTIONS, type Vec } from '../src/sim/types' import { board, v } from './helpers' -const SMALL = { ...DEFAULT_OPTIONS, size: 'small' as const } - -describe('small board', () => { +describe('the board (one size since 2026-10-23)', () => { for (let seed = 1; seed <= 100; seed++) { const level = 1 + (seed % 20) it(`seed ${seed}: 9 × 11, exit and power-up under crates, enemies placed, everything reachable`, () => { - const g = generateLevel(seed, level, 'small') + const g = generateLevel(seed, level) expect([g.width, g.height]).toEqual([9, 11]) const at = (p: Vec) => g.cells[p.y * g.width + p.x] expect(at(g.exit)).toBe('block') expect(at(g.powerUp.pos)).toBe('block') expect(g.powerUp.pos).not.toEqual(g.exit) - expect(g.enemies).toHaveLength(enemyCount(level, 'small')) + expect(g.enemies).toHaveLength(enemyCount(level)) for (const p of [START, { x: 2, y: 1 }, { x: 1, y: 2 }]) expect(at(p)).toBe('floor') const seen = new Set([`${START.x},${START.y}`]) const queue = [START] @@ -35,27 +33,25 @@ describe('small board', () => { }) } - it('has fewer enemies and crates, a lower par, and a 20-level run', () => { - expect(enemyCount(1, 'small')).toBe(2) - expect(enemyCount(50, 'small')).toBe(5) - expect(SIZES.small.density).toBeLessThan(SIZES.normal.density) - expect(lastLevel('small')).toBe(20) - expect(newRun(1, 1, 0, SMALL).par).toBeLessThan(newRun(1, 1, 0).par) + it('2 enemies on level 1, at most 5, and a 20-level run', () => { + expect(enemyCount(1)).toBe(2) + expect(enemyCount(20)).toBe(5) + expect(LAST_LEVEL).toBe(20) }) - it('clearing level 20 wins a small run', () => { + it('clearing level 20 wins the run', () => { const s = { ...board(` #### #PE# - ####`), level: 20, options: SMALL } + ####`), level: 20 } expect(step(s, { id: 'move', target: v(2, 1) }).state.status).toBe('won') }) - it('the next level is small too', () => { - const s = { ...board(` + it('every level is 9 × 11', () => { + const s = board(` #### #PE# - ####`), options: SMALL } + ####`) const next = step(step(s, { id: 'move', target: v(2, 1) }).state, { id: 'continue' }).state expect([next.width, next.height]).toEqual([9, 11]) }) @@ -121,31 +117,29 @@ describe('developer mode', () => { }) }) -describe('god mode', () => { +describe('Immortal (God until 2026-10-23)', () => { it('nothing kills the player', () => { - const god = { ...DEFAULT_OPTIONS, god: true } + const immortal = { ...DEFAULT_OPTIONS, immortal: true } const flame = { ...board(` ##### #P1.# - #####`), options: god } + #####`), options: immortal } expect(step(flame, { id: 'wait' }).state.status).toBe('playing') const enemy = { ...board(` ##### #PB.# - #####`), options: god } + #####`), options: immortal } expect(step(enemy, { id: 'move', target: v(2, 1) }).state.status).toBe('playing') }) }) -describe('crumbling crates', () => { - const crumbly = { ...DEFAULT_OPTIONS, crumble: true } - +describe('crumbling crates (always, since 2026-10-23)', () => { it('once the last enemy dies, every crate goes within 3 turns, revealing the exit, with no punishment', () => { let s = { ...board(` ######### #P...1B+# #+++++#e# - #########`), options: crumbly } + #########`) } const r = step(s, { id: 'wait' }) expect(r.events.map((e) => e.type)).toContain('cratesCrumbling') s = r.state @@ -161,20 +155,22 @@ describe('crumbling crates', () => { expect(s.enemies).toEqual([]) }) - it('without the option, crates stay', () => { + it('while an enemy lives, crates stay', () => { const s = board(` - ######## - #P...1B# - #+###### - ########`) + ######### + #P...1BB# + #+####### + #########`) + s.enemies[1]!.pos = v(7, 1) const r = step(s, { id: 'wait' }).state + expect(r.enemies.length).toBeGreaterThan(0) expect(r.crumbling).toEqual([]) }) }) describe('run record', () => { it('keeps the options, so a replay is the same run', () => { - const options = { size: 'small' as const, dev: true, god: true, crumble: true } + const options = { dev: true, immortal: true } const record = newRecord(77, 1, 0, options) let s = newRun(77, 1, 0, options) for (const a of [{ id: 'wait' as const }, { id: 'wait' as const }]) { diff --git a/test/turn.test.ts b/test/turn.test.ts index bc2aad5..394811c 100644 --- a/test/turn.test.ts +++ b/test/turn.test.ts @@ -1,3 +1,4 @@ +import { LEVEL_WIDTH } from '../src/sim/levelgen' import { describe, expect, it } from 'vitest' import { ACTIONS, isAvailable } from '../src/sim/actions' import { step } from '../src/sim/turn' @@ -95,7 +96,7 @@ describe('death', () => { expect(again.lives).toBe(2) expect(again.stats.fire).toBe(2) expect(again.turn).toBe(0) - expect(again.width).toBe(31) + expect(again.width).toBe(LEVEL_WIDTH) }) it('the last life ends the run', () => { diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index e138a8c..0999661 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -44,8 +44,6 @@ const SHOTS = [ { 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. { name: 'death', act: press('KeyB', 'KeyD', 'Space', 'Space', 'Space') }, { name: 'desktop', viewport: DESKTOP, act: press('KeyB', 'KeyD', 'KeyS') }, @@ -71,9 +69,6 @@ const SHOTS = [ { name: 'gem', query: 'seed=55', act: press('KeyB', 'KeyS', 'KeyD', 'KeyD', 'Space', 'KeyA') }, // ...then walk back and take it: the choice of power-ups. { name: 'gem-offer', query: 'seed=55', act: press('KeyB', 'KeyS', 'KeyD', 'KeyD', 'Space', 'KeyA', 'KeyA', 'KeyS', 'KeyS') }, - // A small board fits the screen whole: no scrolling. - { name: 'small', query: 'small', act: async () => {} }, - { name: 'small-desktop', viewport: DESKTOP, query: 'small', act: async () => {} }, // Developer mode: the level's gem uncovered right of the player, and the mode's tag in the top bar. { name: 'dev', query: 'dev', act: async () => {} }, // "What is that?" (the ? button beside the menu) on, then tapping an enemy explains it; the top bar's items