explodorama / commit
Board sizes, modes, player and enemy animation, playtest fixes
PLAN.md | 68 ++++++++++++++++- README.md | 29 ++++++-- decisions.md | 44 +++++++++++ docs/screenshots/auto-move.png | Bin 248711 -> 249023 bytes docs/screenshots/bomb-picked.png | Bin 248823 -> 248985 bytes docs/screenshots/charges.png | Bin 270548 -> 276870 bytes docs/screenshots/countdown.png | Bin 253449 -> 253578 bytes docs/screenshots/death.png | Bin 214407 -> 198263 bytes docs/screenshots/desktop.png | Bin 593323 -> 593333 bytes docs/screenshots/dev.png | Bin 0 -> 266198 bytes docs/screenshots/enemies.png | Bin 252484 -> 252627 bytes docs/screenshots/info-enemy.png | Bin 192054 -> 192237 bytes docs/screenshots/info-hud.png | Bin 182092 -> 182314 bytes docs/screenshots/lingering.png | Bin 257773 -> 257820 bytes docs/screenshots/long-press.png | Bin 252078 -> 251937 bytes docs/screenshots/menu.png | Bin 186148 -> 190391 bytes docs/screenshots/new-run.png | Bin 175001 -> 194300 bytes docs/screenshots/pierce-decoy.png | Bin 280079 -> 284653 bytes docs/screenshots/power-up.png | Bin 261789 -> 261831 bytes docs/screenshots/run.png | Bin 237155 -> 237216 bytes docs/screenshots/scrolled.png | Bin 261115 -> 261157 bytes docs/screenshots/small-desktop.png | Bin 0 -> 279278 bytes docs/screenshots/small.png | Bin 0 -> 189686 bytes docs/screenshots/start.png | Bin 247709 -> 247835 bytes index.html | 2 +- src/main.ts | 105 ++++++++++++++++++++++---- src/render/board.ts | 81 ++++++++++++++++---- src/render/camera.ts | 5 +- src/render/sprites.ts | 13 ++++ src/sim/levelgen.ts | 65 ++++++++++++---- src/sim/record.ts | 12 +-- src/sim/state.ts | 24 ++++-- src/sim/turn.ts | 78 ++++++++++++++++--- src/sim/types.ts | 22 ++++++ src/style.css | 92 ++++++++++++++++++++--- src/ui/controls.ts | 18 ++++- src/ui/info.ts | 37 +++++++-- test/enemies.test.ts | 41 ++++++++++ test/helpers.ts | 6 +- test/options.test.ts | 149 +++++++++++++++++++++++++++++++++++++ tools/screenshots.mjs | 5 ++ 41 files changed, 798 insertions(+), 98 deletions(-) diff --git a/PLAN.md b/PLAN.md index c4abd62..7f7f023 100644 --- a/PLAN.md +++ b/PLAN.md @@ -213,6 +213,41 @@ NES rules kept (they create the classic tension): --- +## 5b. Exclusive power-ups — a roguelike direction (idea, user, 2026-10-21; *not decided*) + +The user wants to start thinking about making power-ups **exclusive**: picking one up can +**replace** another, so the player can't simply collect everything, and is pushed to +**specialise, experiment and choose** — which to keep, which to drop. A sketch to discuss: + +- **Slots.** The player has a few power-up slots (e.g. 3). Taking a power-up with a free slot + fills it; with all slots full, a dialog asks which one to give up (or to leave the new one). + The dropped power-up stays on that tile, so a swap can be undone while still on the level. +- **What takes a slot.** Natural candidates: the abilities and passes (Skates / Run, Detonator, + Wall / Bomb / Flame Pass), maybe later new ones (Kick as a permanent ability, Pierce bombs…). + Open: do the stat gems (Bomb+, Fire+, Fuse+) also take slots (e.g. "Bomb+ ×2" in one slot, + stacking within it), or stay uncapped as in the NES? Instant ones (Heart, Coin bag, Star) + would not take a slot. +- **Synergies and trade-offs** make choices interesting: e.g. Bomb Pass + Kick, Wall Pass + + Pierce, Flame Pass + Line bombs. A power-up could also have a downside (a "cursed" version). + +**Inconsistencies with the current plan, to resolve before building** (CLAUDE.md asks to raise +these, not paper over them): + +1. **Keep / Fresh on death (§9)** is balanced around *accumulated* stats and abilities + ("Keep: 💣2 🔥3 Run"). With slots, "Keep" keeps the slot loadout; Fresh's payout formula + would need rethinking. +2. **The shop (§8)** sells abilities and stats; with slots, a purchase may also force a swap. +3. **The Workshop (§7)** has tracks like "Bomb satchel +1 starting capacity" and "Skates from + the start" — they'd become starting slot contents or extra slots ("+1 slot" is a natural + Workshop track). +4. **NES rule: passes and Detonator are lost on death (§5, §9)** — with slots, perhaps nothing + is lost on death and the slot choice itself is the cost. +5. **Developer mode (§13b)** gives everything; it would bypass slots (fine for testing). + +Open questions for the user: how many slots? Do stats take slots? Is a swapped-out power-up +gone or left on the tile? Should action charges (one-use, from enemies) stay separate from slots +(probably yes)? + ## 6. Curses (from the *Super Bomberman* skull) - **Never before the first shop** — i.e. not on level 1. From level 2 onward a **skull** can @@ -341,7 +376,9 @@ contact. Intent shown every turn. Adapted from the NES eight: +1 every 3 levels, at most 10. New type every 6 levels: Seeker from 7, Drifter from 13. Enemies may share a tile (they don't block each other). Enemies spawned from a flamed exit drop nothing, so the exit can't be farmed. -- Drops land on the enemy's tile and stay until collected or flamed. +- Drops land on the enemy's tile and stay until collected or flamed. *Built (2026-10-21, user + request):* drops never stack — a drop whose tile is taken (by a drop, a bomb or the uncovered + power-up) rolls to the nearest free floor tile within 2 steps (BFS over floor), else stays. - **Introduction schedule over 50 levels** (like the NES): Blob only at first; one new type every ~6 levels; later levels mix types. Exact table to be drafted in `design.md`. @@ -411,7 +448,8 @@ plan of black Icongeek26 outline icons from Flaticon over a plain drawn board. - **Licence:** CC0, so attribution isn't required, but **the user wants Kenney credited properly**: in the README's Attribution section and in the game (a credit line with a link to - kenney.nl on the death / game-over banner; move it to the About dialog when there is one). The + kenney.nl in the menu — *only there*, user request 2026-10-21; it used to be on the death / + game-over banner too). The pack's `License.txt` is kept in `assets/kenney/`. - **Source files:** only the sprites the game uses (or is about to use, below) are copied, unchanged and under the pack's own file names, into `assets/kenney/`, so each can be found again in the @@ -471,6 +509,24 @@ settings, sound, and most action charges) are still open. --- +## 13b. Run options (user request, 2026-10-21; built) + +Chosen in the **New run** dialog (menu, or after game over / victory), remembered in +`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. + +| 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 Skates rule. Levelgen tops up crates if fewer than 2 are far enough for the exit and power-up. | +| **Developer** | Every ability and pass, 5 bombs, blast 5, unlimited charges (never spent; badges show ∞). Re-applied every level and after every death. **Killable** (the user split it from God mode). Second-row buttons pack five to a row when there are more than 8. | +| **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. | + +URL test parameters `?small`, `?dev`, `?god`, `?crumble` set them for a test board. + +--- + ## 14. Open questions 1. **Workshop banking:** only coins **left over** at the end of a run, or **all** coins earned? @@ -490,6 +546,10 @@ settings, sound, and most action charges) are still open. deterministic) or a simple checksum? 8. **Board theme:** ~~light "paper" board or dark board?~~ Settled by the Kenney art (2026-10-20): a pale sky-blue board under dark bars, coloured as the sprites are. +9. **Exclusive power-ups (§5b):** slots instead of collecting everything? How many, and what + takes one? Needs answers before the shop and Keep / Fresh are built, since both depend on it. +10. **Enemies sharing a tile:** they do now, as in the NES original (as far as remembered). Should + they block each other instead? --- @@ -523,7 +583,9 @@ settings, sound, and most action charges) are still open. the first shop. - **Screenshots:** Playwright at a phone viewport (e.g. 390×844) and desktop, with URL params like `?seed=123&level=1` for reproducible scenes. The user reviews them before any commit. -- **Start simple:** labelled shapes until icons arrive; no animation polish until M7. +- **Start simple:** labelled shapes until icons arrive; no animation polish until M7. *(The + player's animation — idle bob and glance, walking slide, crouch, jump, hit — came early, at + the user's request, 2026-10-21, and enemies got the same hop the same day.)* --- diff --git a/README.md b/README.md index 63d9837..9b875af 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ see [PLAN.md](PLAN.md). ## How to play -You are the green astronaut. **Goal of each level:** destroy every enemy, uncover the exit door +You are the green astronaut (animated: it bobs while waiting, hops from tile to tile, crouches +to place a bomb and jumps when a level is cleared; enemies hop between tiles too). **Goal of each level:** destroy every enemy, uncover the exit door hidden under one of the crates, and step onto it. Each turn you pick **one** action; then the world takes its turn, in this order: enemies step @@ -121,7 +122,8 @@ Level 1 has 3 enemies, with one more every 3 levels (at most 10). Enemies never ### Drops and coins A slain enemy sometimes leaves **coins** (bronze = 1, silver = 2, gold = 5) or an **action -charge** on its tile. Walk onto them to collect them. A later flame burns drops lying in the +charge** on its tile. Drops never share a tile: if one is already there, the new one rolls to the +nearest free floor tile (up to 2 steps away). Walk onto them to collect them. A later flame burns drops lying in the open, so pick them up soon. Coins will buy upgrades in the shop (not built yet). ### The exit @@ -165,9 +167,25 @@ Wall Pass, Bomb Pass, Flame Pass, the Detonator and a Star (as in the original); what you lost. 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 + +**Menu → New run** (or the button after game over / victory) lets you choose: + +| Choice | 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) | Every power-up and ability (Run, Detonate, all three passes), **unlimited** action charges (badges show ∞), 5 bombs, blast 5 — for trying everything out. You can still be killed. | +| **God** (checkbox) | Nothing can kill you. | +| **Crumbling crates** (checkbox) | Once the last enemy is gone, every crate crumbles away over 1–3 turns (shaking first), revealing the exit and the power-up. Not flame: nothing is punished. | + +The choice is remembered for next time, is fixed for the run, and shows as small yellow tags at +the left of the top bar (tap them for details) and in the menu. Starting a new run while one is +going warns that it will be lost. + ### The run -**50 levels.** Clearing level 50 ends the run with a victory banner: total turns, lives lost +**50 levels** (20 on a small board). 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. @@ -194,7 +212,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 with 2 of every action charge, for trying them -out. A URL with any of these parameters plays a test board: it doesn't load or overwrite your +out; `?small`, `?dev`, `?god` and `?crumble` set the board size and modes. A URL with any of these parameters plays a test board: it doesn't load or overwrite your saved run. Deployed to GitLab Pages by `.gitlab-ci.yml` on every push to the default branch. @@ -222,8 +240,7 @@ Art: [New Platformer Pack](https://kenney.nl/assets/new-platformer-pack) 1.1, [UI Pack: RPG Expansion](https://kenney.nl/assets/ui-pack-rpg-expansion), all by [Kenney](https://www.kenney.nl), under [CC0](http://creativecommons.org/publicdomain/zero/1.0/) (public domain). Kenney doesn't require credit, but the pack was chosen deliberately and the game -looks the way it does because of it, so it is credited here and in the game (in the menu, and on -the banner shown after each death). Thank you, Kenney! Support his work at <https://www.kenney.nl/donate>. +looks the way it does because of it, so it is credited here and in the game (in the menu). Thank you, Kenney! Support his work at <https://www.kenney.nl/donate>. The files used are copied, unchanged and under their original file names, into [`assets/kenney/`](assets/kenney/), [`assets/kenney-ui/`](assets/kenney-ui/) (keeping the pack's diff --git a/decisions.md b/decisions.md index f8b18e3..de8f1ba 100644 --- a/decisions.md +++ b/decisions.md @@ -193,3 +193,47 @@ fix, two parts: README and the menu like the platformer pack. The action bar keeps its CSS-drawn buttons for now; they already follow the same look. +### 2026-10-21 — Board sizes, Developer / God / crumbling-crates modes, player animation + +Decisions made with the user: + +- **Small board = 9 × 11, 20 levels**, fewer enemies (2 → 5) and crates (25%). 9 × 11 keeps the + NES pillar pattern and fits a 390-wide phone at about 43 px a tile. +- **Developer and God are separate checkboxes** (the user split them mid-way): Developer gives + everything (every ability and pass, unlimited charges, 5 bombs, blast 5) but stays killable, + "mainly to test out all the possible actions and power-ups"; God makes the player unkillable. + They combine. +- **Crumbling crates: each crate 1–3 turns**, seeded, once the last enemy dies. Not flame, so + nothing is punished. +- **Options are chosen in a New-run dialog** and remembered for next time; not asked on the + very first launch. + +Made by me: + +- **Options are part of the run record and state** (`SIM_VERSION` 5), so replays and saves stay + exact; older saves aren't resumed. +- **Developer charges are 99 that are never spent**, shown as ∞, rather than a special case in + every check. +- **The new-run dialog replaces the separate "Start a new run?" confirmation**: it carries the + warning itself when a run is in progress. +- **Player animation is drawn, not simulated:** the sim still moves the player instantly; the + renderer slides the sprite over 0.16 s on walking frames, crouches for a bomb, jumps on a + cleared level, bobs and glances at the camera when idle, shakes in the hit pose. Teleports + just appear. Enemies got the same hop afterwards (user request): each `enemyMoved` event + slides that enemy over the same 0.16 s. + +### 2026-10-21 — Playtest feedback: credit, wall info, drops, enemy hops + +- **The Kenney credit is only in the menu** (the user's "settings / about" screen), no longer on + the death / game-over banner. +- **Wall info shows the exact tile**: the stone outer wall, or the pillar in this level's + colour, titled "Outer wall" or "Pillar". +- **Drops never share a tile.** A drop whose tile is taken (a drop, a bomb, the uncovered + power-up) rolls to the nearest free floor tile within 2 steps, searching over floor only (not + through walls or crates), else stays. It changes where drops land, so `SIM_VERSION` is 6 (a + run saved while testing version 5 would not replay the same). +- **Enemies hop between tiles** like the player. +- **Enemies sharing a tile is unchanged** for now: the NES original let enemies pass through + each other (from memory, not verified). Listed as open question 10 in PLAN.md §14. +- **Exclusive power-ups** are written up as an idea with its conflicts (PLAN.md §5b), not built. + diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png index 4b18454..ce373cf 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 8826509..2781573 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 7a0c39f..70222b6 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 72d3c92..06b899d 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 cffcc5d..44fc872 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 e85a36b..47affca 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 new file mode 100644 index 0000000..d6007a1 Binary files /dev/null and b/docs/screenshots/dev.png differ diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png index 3ebf454..74e9193 100644 Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ diff --git a/docs/screenshots/info-enemy.png b/docs/screenshots/info-enemy.png index 7c758dd..7b18125 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 03168b3..189e32c 100644 Binary files a/docs/screenshots/info-hud.png and b/docs/screenshots/info-hud.png differ diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png index 6314908..1ca8059 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 963e501..dd650c1 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 a8706d0..fbf0b1d 100644 Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ diff --git a/docs/screenshots/new-run.png b/docs/screenshots/new-run.png index 9545bbe..4b25e7b 100644 Binary files a/docs/screenshots/new-run.png and b/docs/screenshots/new-run.png differ diff --git a/docs/screenshots/pierce-decoy.png b/docs/screenshots/pierce-decoy.png index ccf311b..3103de5 100644 Binary files a/docs/screenshots/pierce-decoy.png and b/docs/screenshots/pierce-decoy.png differ diff --git a/docs/screenshots/power-up.png b/docs/screenshots/power-up.png index c5d51fe..538df8c 100644 Binary files a/docs/screenshots/power-up.png and b/docs/screenshots/power-up.png differ diff --git a/docs/screenshots/run.png b/docs/screenshots/run.png index 81c9777..b48073b 100644 Binary files a/docs/screenshots/run.png and b/docs/screenshots/run.png differ diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png index ae2178a..5df6f6d 100644 Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ diff --git a/docs/screenshots/small-desktop.png b/docs/screenshots/small-desktop.png new file mode 100644 index 0000000..1ea247b Binary files /dev/null and b/docs/screenshots/small-desktop.png differ diff --git a/docs/screenshots/small.png b/docs/screenshots/small.png new file mode 100644 index 0000000..90c6a19 Binary files /dev/null and b/docs/screenshots/small.png differ diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png index 4e47755..7473295 100644 Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ diff --git a/index.html b/index.html index c2d1a92..066474e 100644 --- a/index.html +++ b/index.html @@ -13,6 +13,7 @@ </head> <body> <header id="hud" aria-live="polite"> + <span id="hud-modes"></span> <span id="hud-lives"></span> <span id="hud-level"></span> <span id="hud-enemies"></span> @@ -26,7 +27,6 @@ <img id="banner-icon" alt="" /> <p id="banner-text"></p> <button id="banner-button" type="button"></button> - <p class="credit">Art by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a> · New Platformer Pack, UI Pack, UI Pack: RPG Expansion (CC0)</p> </div> <dialog id="dialog" aria-labelledby="dialog-title"> <div class="panel" tabindex="-1"> diff --git a/src/main.ts b/src/main.ts index 66fb217..b783078 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,7 +10,7 @@ import { time, vec2, } from 'littlejsengine' -import { BACKGROUND, drawBoard, FLAME_SECONDS, toTile, type Flame } from './render/board' +import { BACKGROUND, drawBoard, FLAME_SECONDS, toTile, type Flame, type PlayerAnim } from './render/board' import { updateCamera } from './render/camera' import { TEXTURES } from './render/sprites' import { isAvailable, isValidTarget, ACTIONS } from './sim/actions' @@ -20,10 +20,10 @@ import { predictDanger, type Danger } from './sim/danger' import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record' import { bombAt, cellAt, eq, idx, inBounds, isBurning, 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 { DEFAULT_OPTIONS, DIRS, type Action, type ActionId, type Dir, type GameState, type RunOptions, type Vec } from './sim/types' import { createControls } from './ui/controls' import { dialogOpen, openDialog, type DialogButton } from './ui/dialog' -import { hudInfo, tileInfo, type Info } from './ui/info' +import { hudInfo, modeLines, tileInfo, type Info } from './ui/info' import { spriteUrl } from './render/sprites' import playIcon from '../assets/kenney-ui/Extra/icon_play_light.png' import newRunIcon from '../assets/kenney-ui/Extra/icon_repeat_light.png' @@ -42,6 +42,8 @@ let selected: ActionId | null = null let hover: Vec | null = null let flames: Flame[] = [] let facingLeft = false +let playerAnim: PlayerAnim = { kind: 'none', from: { x: 0, y: 0 }, at: 0 } +const enemySteps = new Map<number, { from: Vec; at: number }>() 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 @@ -52,17 +54,38 @@ let pressTimer: number | undefined let pressTile: Vec | null = null const LONG_PRESS_MS = 450 -function startRun(seed: number): void { +function startRun(seed: number, options: RunOptions): void { + playerAnim = { kind: 'none', from: { x: 0, y: 0 }, at: 0 } + enemySteps.clear() selected = null destination = null flames = [] snapCamera = true - record = newRecord(seed, startLevel, startCharges) - state = newRun(seed, startLevel, startCharges) + record = newRecord(seed, startLevel, startCharges, options) + state = newRun(seed, startLevel, startCharges, options) save() afterChange() } +/** The board size and modes picked last time, the default for the next new run. */ +const OPTIONS_KEY = 'explodorama.newRunOptions' + +function preferredOptions(): RunOptions { + try { + return { ...DEFAULT_OPTIONS, ...(JSON.parse(localStorage.getItem(OPTIONS_KEY) ?? '{}') as Partial<RunOptions>) } + } catch { + return { ...DEFAULT_OPTIONS } + } +} + +function rememberOptions(options: RunOptions): void { + try { + localStorage.setItem(OPTIONS_KEY, JSON.stringify(options)) + } catch { + // No storage: the choice just isn't remembered. + } +} + /** * Mid-run save (PLAN.md §11): the run record goes to localStorage after every action, and is * replayed on the next visit. A URL that sets up a test board (`?seed`, `?level`, `?charges`) @@ -70,7 +93,12 @@ function startRun(seed: number): void { * missing or full; then the game simply doesn't save. */ const SAVE_KEY = 'explodorama.run' -const testing = params.has('seed') || params.has('level') || params.has('charges') + +/** A test URL's options: `?small`, `?dev`, `?god`, `?crumble` (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') } +} +const testing = ['seed', 'level', 'charges', 'small', 'dev', 'god', 'crumble'].some((p) => params.has(p)) function save(): void { if (testing) return @@ -115,6 +143,18 @@ function act(action: Action): void { 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 or run slides; a teleport (further than a run) just appears. + if (e.type === 'moved') { + const far = Math.abs(e.to.x - e.from.x) + Math.abs(e.to.y - e.from.y) > 2 + playerAnim = { kind: far ? 'none' : 'walk', from: e.from, at: time } + } + if (e.type === 'bombPlaced' && playerAnim.kind !== 'walk') playerAnim = { kind: 'duck', from: state.player, at: time } + if (e.type === 'levelCleared') playerAnim = { kind: 'jump', from: state.player, at: time } + if (e.type === 'levelStarted' || e.type === 'levelRestarted') { + playerAnim = { kind: 'none', from: state.player, at: time } + enemySteps.clear() + } + if (e.type === 'enemyMoved') enemySteps.set(e.id, { from: e.from, at: time }) } selected = null save() @@ -195,26 +235,59 @@ function showMenu(): void { title: 'Menu', body: `<p>${where}Seed ${state.seed}. The game saves after every move, so you can close it and come back.</p>` + + modeLines(state).map((l) => `<p class="mode">${l}</p>`).join('') + `<p class="credit">Art by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a>: ` + `New Platformer Pack, UI Pack and UI Pack: RPG Expansion (CC0). Thank you!</p>`, buttons: [ { label: 'Resume', kind: 'primary', icon: playIcon }, - { label: 'New run', kind: 'danger', icon: newRunIcon, onClick: confirmNewRun }, + { label: 'New run', kind: 'danger', icon: newRunIcon, onClick: newRunDialog }, ], }) } -function confirmNewRun(): 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. + */ +function newRunDialog(): void { + const o = preferredOptions() + const ongoing = state.status !== 'gameOver' && state.status !== 'won' + const checked = (on: boolean) => (on ? ' checked' : '') + const warning = ongoing + ? `<p class="warning">This run — level ${state.level}, ${state.coins} coins, ${state.lives} ${state.lives === 1 ? 'life' : 'lives'} — will be lost. This can’t be undone.</p>` + : '' openDialog({ - title: 'Start a new run?', - body: `<p>This run — level ${state.level}, ${state.coins} coins, ${state.lives} ${state.lives === 1 ? 'life' : 'lives'} — will be lost. This can’t be undone.</p>`, + 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> Every power-up and ability, unlimited action charges, 5 bombs, blast 5. You can still be killed.</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>`, buttons: [ - { label: 'Start a new run', kind: 'danger', icon: newRunIcon, onClick: () => startRun(randomSeed()) }, - { label: 'Keep playing', kind: 'plain' }, + { label: 'Start', kind: ongoing ? 'danger' : 'primary', icon: newRunIcon, onClick: startChosenRun }, + { label: ongoing ? 'Keep playing' : 'Cancel', kind: 'plain' }, ], }) } +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'), + } + rememberOptions(options) + startRun(randomSeed(), options) +} + /** * 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. @@ -257,7 +330,7 @@ const controls = createControls({ pick, banner() { if (state.status === 'dead' || state.status === 'cleared') act({ id: 'continue' }) - else if (state.status === 'gameOver' || state.status === 'won') startRun(randomSeed()) + else if (state.status === 'gameOver' || state.status === 'won') newRunDialog() }, info: (item) => showInfo(hudInfo(item, state)), menu: showMenu, @@ -316,7 +389,7 @@ function tileUnder(e: PointerEvent): Vec | null { return inBounds(state, p) ? p : null } -if (!resumeSaved()) startRun(Number(params.get('seed')) || randomSeed()) +if (!resumeSaved()) startRun(Number(params.get('seed')) || randomSeed(), testing ? urlOptions() : preferredOptions()) // Sharp on high-density screens, capped so phones don't pay for 3×. setCanvasPixelRatio(Math.min(devicePixelRatio, 2)) @@ -367,7 +440,7 @@ await engineInit( () => {}, () => drawBoard({ - state, danger, selected, hover, flames, facingLeft, destination, path, armed, + state, danger, selected, hover, flames, facingLeft, destination, path, armed, playerAnim, enemySteps, pathBlocked: !!path?.[0] && !safeStep(path[0]), }), () => {}, diff --git a/src/render/board.ts b/src/render/board.ts index b00ca2f..dff6db7 100644 --- a/src/render/board.ts +++ b/src/render/board.ts @@ -5,7 +5,7 @@ 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 { CHARGE_ICONS, coinSprite, digitSprite, ENEMY_SPRITES, POWER_UP_ICONS, sprite, type BoardSprite } from './sprites' +import { CHARGE_ICONS, coinSprite, digitSprite, ENEMY_SPRITES, POWER_UP_ICONS, sprite, wallSprite, type BoardSprite } from './sprites' /** * The one place where sim tiles meet LittleJS world space. The sim's y grows downward @@ -41,8 +41,27 @@ 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 + /** Each enemy's latest step, by id: where from, and when (LittleJS `time`). */ + enemySteps: ReadonlyMap<number, { from: Vec; at: number }> } +/** + * walk: slides from `from` to the player's tile on walking frames. duck: crouches (placing a + * bomb). jump: jumps for joy (level cleared), until the next level. none: idle. + */ +export interface PlayerAnim { + kind: 'none' | 'walk' | 'duck' | 'jump' + from: Vec + /** LittleJS `time` when it started. */ + at: number +} + +/** Seconds a step's slide takes, and a crouch. */ +export const WALK_SECONDS = 0.16 +const DUCK_SECONDS = 0.22 + export const FLAME_SECONDS = 0.45 const C = { @@ -69,15 +88,13 @@ const C = { export const BACKGROUND: Color = C.sky -/** Pillars change colour from level to level; the outer wall is always stone. */ -const PILLARS: BoardSprite[] = ['block_blue', 'block_green', 'block_red', 'block_yellow'] export function drawBoard(view: View): void { const { state: s, danger, selected, hover } = view // A hair over a tile, so neighbouring walls overlap and no anti-aliased seam shows between them. const wallSize = vec2(1.02) const tileSize = vec2(1) - const pillar = sprite(PILLARS[(s.level - 1) % PILLARS.length]!) + const crumbling = new Map(s.crumbling.map((c) => [c.cell, c.turns])) for (let y = 0; y < s.height; y++) { for (let x = 0; x < s.width; x++) { @@ -85,10 +102,16 @@ export function drawBoard(view: View): void { const pos = toWorld(s, p) const cell = s.cells[idx(s, p)] drawRect(pos, wallSize, (x + y) % 2 ? C.floorAlt : C.floor) - if (cell === 'wall') { - const edge = x === 0 || y === 0 || x === s.width - 1 || y === s.height - 1 - drawTile(pos, wallSize, edge ? sprite('terrain_stone_block_center') : pillar) - } else if (cell === 'block') drawTile(pos, tileSize, sprite('block_planks')) + if (cell === 'wall') drawTile(pos, wallSize, sprite(wallSprite(s.level, s.width, s.height, p))) + else if (cell === 'block') { + // A crumbling crate shakes, harder and fainter the closer it is to gone. + const left = crumbling.get(idx(s, p)) + if (left === undefined) drawTile(pos, tileSize, sprite('block_planks')) + else { + const shake = (0.05 / left) * Math.sin(time * 30 + x * 7 + y * 3) + drawTile(pos.add(vec2(shake, 0)), vec2(1 - 0.08 / left), sprite('block_planks'), C.white.scale(1, 0.45 + 0.18 * left), shake) + } + } } } if (cellAt(s, s.exit) === 'floor') drawExit(toWorld(s, s.exit), exitOpen(s)) @@ -158,7 +181,7 @@ export function drawBoard(view: View): void { drawTile(pos, vec2(0.62), sprite(d.kind === 'coins' ? coinSprite(d.amount) : CHARGE_ICONS[d.kind])) } - for (const e of s.enemies) drawEnemy(s, e) + for (const e of s.enemies) drawEnemy(s, e, view.enemySteps.get(e.id)) if (s.shield) drawCircle(toWorld(s, s.player), 1.08, C.clear, 0.07, C.shield) // A Star: a pulsing gold ring for as long as it lasts. @@ -166,8 +189,38 @@ export function drawBoard(view: View): void { 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) + drawPlayer(s, view.playerAnim, view.facingLeft) +} + +/** + * 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. + */ +function drawPlayer(s: GameState, anim: PlayerAnim, facingLeft: boolean): void { + const to = toWorld(s, s.player) + const t = time - anim.at + let pos = to + let frame: BoardSprite = 'character_green_idle' + if (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 === 'jump') { + frame = 'character_green_jump' + pos = to.add(vec2(0, 0.18 * Math.abs(Math.sin(time * 6)))) + } else if (anim.kind === 'walk' && t < WALK_SECONDS) { + 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) { + frame = 'character_green_duck' + } else { + // Idle: a slow bob, and every 4 seconds a short look at the camera. + pos = to.add(vec2(0, 0.025 * Math.sin(time * 3))) + if (time % 4 > 3.4) frame = 'character_green_front' + } + drawTile(pos, vec2(1), sprite(frame), C.white, 0, facingLeft) } /** The pack's door is two tiles tall; it is drawn narrower so both halves fit about one tile. */ @@ -185,8 +238,10 @@ const PLAN_ALPHA = [0.85, 0.55, 0.3] * 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) +function drawEnemy(s: GameState, e: Enemy, stepped?: { from: Vec; at: number }): void { + // A step just taken: hop from the old tile to the new one, like the player. + const k = stepped ? (time - stepped.at) / WALK_SECONDS : 1 + const pos = k < 1 ? toWorld(s, stepped!.from).lerp(toWorld(s, e.pos), k).add(vec2(0, 0.08 * Math.sin(k * Math.PI))) : toWorld(s, e.pos) const art = ENEMY_SPRITES[e.kind] const moving = e.plan.length > 0 const frame = !moving ? art.rest : Math.floor(time * 2.5 + e.id) % 2 ? art.a : art.b diff --git a/src/render/camera.ts b/src/render/camera.ts index ef9803c..6a1b9a5 100644 --- a/src/render/camera.ts +++ b/src/render/camera.ts @@ -11,14 +11,15 @@ 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). + * 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. * `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 = available / s.height + const scale = s.options.size === 'small' ? Math.min(available / s.height, width / s.width) : available / s.height setCameraScale(scale) const halfView = width / 2 / scale diff --git a/src/render/sprites.ts b/src/render/sprites.ts index 52c3939..bf43d52 100644 --- a/src/render/sprites.ts +++ b/src/render/sprites.ts @@ -57,6 +57,10 @@ const BOARD = [ 'bridge_logs', 'hud_player_helmet_green', 'character_green_front', + 'character_green_walk_a', + 'character_green_walk_b', + 'character_green_duck', + 'character_green_jump', 'gem_blue', 'gem_red', 'gem_yellow', @@ -97,6 +101,15 @@ export function digitSprite(d: number): TileInfo { return sprite(`hud_character_${d}` as BoardSprite) } +/** Pillars change colour from level to level; the outer wall is always stone. */ +const PILLARS: BoardSprite[] = ['block_blue', 'block_green', 'block_red', 'block_yellow'] + +/** The sprite of a wall tile: stone on the level's edge, otherwise the level's pillar colour. */ +export function wallSprite(level: number, width: number, height: number, p: { x: number; y: number }): BoardSprite { + const edge = p.x === 0 || p.y === 0 || p.x === width - 1 || p.y === height - 1 + return edge ? 'terrain_stone_block_center' : PILLARS[(level - 1) % PILLARS.length]! +} + /** The icon for each action charge, on the board (as a drop) and on its button. */ export const CHARGE_ICONS: Record<ChargeId, BoardSprite> = { kick: 'spring_out', diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts index 79c6d4f..8d0a224 100644 --- a/src/sim/levelgen.ts +++ b/src/sim/levelgen.ts @@ -1,5 +1,5 @@ import { hashSeed, stream, type Rng } from './rng' -import { DIR_LIST, type Cell, type Dir, type EnemyKind, type PowerUp, type PowerUpKind, type Vec } from './types' +import { DIR_LIST, type BoardSize, type Cell, type Dir, type EnemyKind, type PowerUp, type PowerUpKind, 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 @@ -19,18 +19,48 @@ export function enemyKinds(level: number): EnemyKind[] { return all.slice(0, 1 + Math.floor((level - 1) / 6)) } -/** 3 on level 1, one more every 3 levels, at most 10. To tune by playtesting. */ -export function enemyCount(level: number): number { - return Math.min(10, 3 + Math.floor((level - 1) / 3)) +/** 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 } -/** The turn budget for the par bonus: 50 plus 10 per enemy. To tune by playtesting. */ -export function parFor(enemies: number): number { - return 50 + 10 * enemies +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, + }, } -/** The run's last level; clearing it wins (PLAN.md §11). */ -export const LAST_LEVEL = 50 +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 +} /** * How likely each power-up is to be a level's hidden one. Skates aren't in the draw: they are @@ -72,9 +102,8 @@ export interface Layout { par: number } -export function generateLevel(seed: number, level: number): Layout { - const width = LEVEL_WIDTH - const height = LEVEL_HEIGHT +export function generateLevel(seed: number, level: number, size: BoardSize = 'normal'): Layout { + const { width, height, density } = SIZES[size] const rng = stream(seed, 'levelgen', level) const cells: Cell[] = [] for (let y = 0; y < height; y++) { @@ -90,26 +119,32 @@ export function generateLevel(seed: number, level: number): Layout { for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const i = y * width + x - if (cells[i] === 'floor' && !clear.has(key({ x, y })) && rng.next() < BLOCK_DENSITY) cells[i] = 'block' + if (cells[i] === 'floor' && !clear.has(key({ x, y })) && rng.next() < density) cells[i] = 'block' } } const far = (i: number) => Math.abs((i % width) - START.x) + Math.abs(Math.floor(i / width) - START.y) > SAFE_DISTANCE const at = (i: number): Vec => ({ x: i % width, y: Math.floor(i / width) }) + // A small board can, rarely, have too few crates far enough away for the exit and the + // power-up: turn far floor tiles into crates until there are two. + for (let n = cells.filter((c, i) => c === 'block' && far(i)).length; n < 2; n++) { + const floors = cells.flatMap((c, i) => (c === 'floor' && far(i) ? [i] : [])) + cells[floors[rng.int(floors.length)]!] = 'block' + } const blocks = cells.flatMap((c, i) => (c === 'block' && far(i) ? [i] : [])) const exit = at(blocks.splice(rng.int(blocks.length), 1)[0]!) const open = cells.flatMap((c, i) => (c === 'floor' && far(i) ? [i] : [])) const kinds = enemyKinds(level) const enemies: EnemyStart[] = [] - for (let n = enemyCount(level); n > 0 && open.length; n--) { + for (let n = enemyCount(level, size); 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 power-up is possible. const others = cells.flatMap((c, i) => (c === 'block' && !(i % width === exit.x && Math.floor(i / width) === exit.y) ? [i] : [])) const powerUp = { pos: at(others[rng.int(others.length)]!), kind: pickPowerUp(seed, level, rng) } - return { width, height, cells, start: { ...START }, exit, powerUp, enemies, par: parFor(enemies.length) } + return { width, height, cells, start: { ...START }, exit, powerUp, enemies, par: parFor(enemies.length, size) } } function key(p: Vec): string { diff --git a/src/sim/record.ts b/src/sim/record.ts index 53faefc..3ca2d50 100644 --- a/src/sim/record.ts +++ b/src/sim/record.ts @@ -1,12 +1,12 @@ import { newRun } from './state' import { step } from './turn' -import type { Action, GameState } from './types' +import { DEFAULT_OPTIONS, type Action, type GameState, type RunOptions } 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 = 4 +export const SIM_VERSION = 6 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */ export interface RunRecord { @@ -15,15 +15,17 @@ export interface RunRecord { startLevel: number /** Action charges of each kind given at the start (testing aid; 0 in a normal run). */ startCharges: number + /** Board size and modes, chosen when the run started. */ + options: RunOptions actions: Action[] } -export function newRecord(seed: number, startLevel = 1, startCharges = 0): RunRecord { - return { simVersion: SIM_VERSION, seed, startLevel, startCharges, actions: [] } +export function newRecord(seed: number, startLevel = 1, startCharges = 0, options: RunOptions = DEFAULT_OPTIONS): RunRecord { + return { simVersion: SIM_VERSION, seed, startLevel, startCharges, options: { ...options }, actions: [] } } export function replay(record: RunRecord): GameState { - let s = newRun(record.seed, record.startLevel, record.startCharges) + let s = newRun(record.seed, record.startLevel, record.startCharges, record.options) for (const a of record.actions) s = step(s, a).state return s } diff --git a/src/sim/state.ts b/src/sim/state.ts index 08a3150..eea7958 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 { CHARGE_IDS, type Bomb, type Cell, type ChargeId, type GameState, type Powers, type Stats, type Vec } from './types' +import { CHARGE_IDS, DEFAULT_OPTIONS, type Bomb, type Cell, type ChargeId, type GameState, type Powers, type RunOptions, type Stats, type Vec } from './types' export const START_LIVES = 3 export const START_STATS: Stats = { capacity: 1, fire: 1, fuse: 3 } @@ -34,9 +34,13 @@ export function noCharges(): Record<ChargeId, number> { return charges(0) } +/** Developer mode's stats, and its charges (shown as unlimited; never spent). */ +export const DEV_STATS: Stats = { capacity: 5, fire: 5, fuse: 3 } +export const DEV_CHARGES = 99 + /** `n`: start with this many of every action charge (a testing aid, `?charges=` in the URL). */ -export function newRun(seed: number, level = 1, n = 0): GameState { - return createLevel(seed, level, { +export function newRun(seed: number, level = 1, n = 0, options: RunOptions = DEFAULT_OPTIONS): GameState { + return createLevel(seed, level, options, { lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charges: charges(n), powers: noPowers(), star: 0, runTurns: 0, deaths: 0, }) @@ -46,18 +50,24 @@ export function newRun(seed: number, level = 1, n = 0): GameState { * A fresh attempt at a level: the layout and enemies come from the seed, so a retry after a death * is the same board. What the player has comes with them (`carryOf` decides what a death costs). */ -export function createLevel(seed: number, level: number, carry: Carry): GameState { - const { width, height, cells, start, exit, powerUp, enemies, par } = generateLevel(seed, level) +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 s: GameState = { - seed, level, turn: 0, width, height, cells, player: start, + seed, options: { ...options }, 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, plan: [], spawned: false })), nextEnemyId: enemies.length, drops: [], exit, par, shield: false, frozen: false, decoy: null, burning: [], - powers: { ...carry.powers }, star: carry.star, powerUp, runTurns: carry.runTurns, deaths: carry.deaths, + powers: { ...carry.powers }, star: carry.star, powerUp, runTurns: carry.runTurns, deaths: carry.deaths, crumbling: [], aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level), } + // Developer mode: everything, every level and after every death. + if (options.dev) { + s.powers = { run: true, detonate: true, wallPass: true, bombPass: true, flamePass: true } + s.stats = { ...DEV_STATS, fuse: Math.max(DEV_STATS.fuse, s.stats.fuse) } + s.charges = charges(DEV_CHARGES) + } planIntents(s) return s } diff --git a/src/sim/turn.ts b/src/sim/turn.ts index 8551e3c..2e6b6f0 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -1,9 +1,10 @@ import { ACTIONS, chargeOf, isAvailable, isValidTarget } from './actions' import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies' -import { enemyKinds, LAST_LEVEL } from './levelgen' +import { enemyKinds, lastLevel } from './levelgen' import { detonate, tickBombs } from './explosions' -import { add, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, PAR_BONUS } from './state' -import type { Action, GameEvent, GameState, PowerUpKind, Vec } from './types' +import { add, bombAt, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, PAR_BONUS } from './state' +import { stream } from './rng' +import { DIRS, DIR_LIST, type Action, type GameEvent, type GameState, type PowerUpKind, type Vec } from './types' export interface StepResult { state: GameState @@ -28,11 +29,11 @@ export function step(prev: GameState, action: Action): StepResult { if (action.id === 'continue') { if (s.status === 'dead') { events.push({ type: 'levelRestarted' }) - return { state: createLevel(s.seed, s.level, carryOf(s, true)), events } + return { state: createLevel(s.seed, s.level, s.options, carryOf(s, true)), events } } if (s.status === 'cleared') { events.push({ type: 'levelStarted', level: s.level + 1 }) - return { state: createLevel(s.seed, s.level + 1, carryOf(s)), events } + return { state: createLevel(s.seed, s.level + 1, s.options, carryOf(s)), events } } throw new Error(`continue while ${s.status}`) } @@ -45,7 +46,7 @@ export function step(prev: GameState, action: Action): StepResult { } const charge = chargeOf(action.id) if (charge) { - s.charges[charge]-- + if (!s.options.dev) s.charges[charge]-- events.push({ type: 'chargeUsed', id: charge }) } const from = s.player @@ -60,7 +61,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 >= LAST_LEVEL) { + if (s.level >= lastLevel(s.options.size)) { s.status = 'won' events.push({ type: 'runWon', turns: s.runTurns, deaths: s.deaths }) } else s.status = 'cleared' @@ -71,7 +72,7 @@ export function step(prev: GameState, action: Action): StepResult { // Flame Pass stops flame. let shielded = false const hurt = (cause: 'flame' | 'enemy'): void => { - if (shielded || s.star > 0 || (cause === 'flame' && s.powers.flamePass)) return + if (s.options.god || shielded || s.star > 0 || (cause === 'flame' && s.powers.flamePass)) return if (s.shield) { s.shield = false shielded = true @@ -100,6 +101,7 @@ export function step(prev: GameState, action: Action): StepResult { const enemiesBefore = s.enemies.length tickBombs(s, events) const burned = detonate(s, events) + crumble(s, events) // 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 @@ -114,6 +116,7 @@ export function step(prev: GameState, action: Action): StepResult { 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)) { + drop.pos = dropSpot(s, e.pos) s.drops.push(drop) events.push({ type: 'dropSpawned', drop }) } @@ -136,7 +139,10 @@ export function step(prev: GameState, action: Action): StepResult { events.push({ type: 'powerUpRevealed', powerUp: s.powerUp }) } } - if (enemiesBefore > 0 && s.enemies.length === 0) events.push({ type: 'exitOpened', pos: s.exit }) + if (enemiesBefore > 0 && s.enemies.length === 0) { + events.push({ type: 'exitOpened', pos: s.exit }) + if (s.options.crumble) startCrumbling(s, events) + } // This turn's flame lingers through the next turn; last turn's goes out. s.burning = [...burned] @@ -154,6 +160,60 @@ export function step(prev: GameState, action: Action): StepResult { return { state: s, events } } +/** How far (in steps) a drop may roll from where the enemy died to find a tile of its own. */ +export const DROP_SPREAD = 2 + +/** + * Where a new drop lands: the enemy's tile if nothing lies there yet, otherwise the nearest + * open floor tile (within DROP_SPREAD steps, never through walls or crates) without a drop, a + * bomb or the uncovered power-up — so drops never pile up on one tile. If there is none, it + * stays on the enemy's tile after all. + */ +function dropSpot(s: GameState, from: Vec): Vec { + const taken = (p: Vec) => + s.drops.some((d) => eq(d.pos, p)) || !!bombAt(s, p) || (!!s.powerUp && eq(s.powerUp.pos, p) && cellAt(s, p) === 'floor') + const seen = new Set([idx(s, from)]) + let ring = [from] + for (let dist = 0; dist <= DROP_SPREAD && ring.length; dist++) { + const free = ring.find((p) => !taken(p)) + if (free) return { ...free } + const next: Vec[] = [] + for (const p of ring) { + for (const d of DIR_LIST) { + const q = add(p, DIRS[d]) + if (seen.has(idx(s, q)) || cellAt(s, q) !== 'floor') continue + seen.add(idx(s, q)) + next.push(q) + } + } + ring = next + } + return { ...from } +} + +/** The crumble option: once the last enemy is gone, every crate gets 1–3 turns before it goes. */ +function startCrumbling(s: GameState, events: GameEvent[]): void { + if (s.crumbling.length) return + const rng = stream(s.seed, 'crumble', s.level) + s.cells.forEach((c, cell) => { + if (c === 'block') s.crumbling.push({ cell, turns: 1 + rng.int(3) }) + }) + events.push({ type: 'cratesCrumbling', count: s.crumbling.length }) +} + +/** + * Phase 3, after the blasts: crumbling crates count down, and those at 0 are gone. It is not + * flame: nothing burns, nothing is punished, and an exit or power-up under one is simply revealed. + */ +function crumble(s: GameState, events: GameEvent[]): void { + for (const c of s.crumbling) { + if (--c.turns > 0 || s.cells[c.cell] !== 'block') continue + s.cells[c.cell] = 'floor' + events.push({ type: 'crateCrumbled', pos: { x: c.cell % s.width, y: Math.floor(c.cell / s.width) } }) + } + s.crumbling = s.crumbling.filter((c) => c.turns > 0) +} + /** A group of the level's toughest enemy type comes out at `pos` (NES rule, PLAN.md §5). They drop nothing. */ function spawnPunishment(s: GameState, pos: Vec, events: GameEvent[]): void { const kinds = enemyKinds(s.level) diff --git a/src/sim/types.ts b/src/sim/types.ts index ebe23bf..d105d93 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -50,6 +50,23 @@ 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. */ +export interface RunOptions { + /** normal: 31 × 13, 50 levels. small: 9 × 11, 20 levels, fewer enemies and crates. */ + size: BoardSize + /** Developer mode: every ability and pass, unlimited charges, 5 bombs, blast 5 — for trying things. Still killable. */ + 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 +} + +export const DEFAULT_OPTIONS: RunOptions = { size: 'normal', dev: false, god: false, crumble: false } + /** The power-ups hidden one per level under a soft block (PLAN.md §5, after the NES eight). */ export type PowerUpKind = | 'bombPlus' | 'firePlus' | 'fusePlus' | 'skates' | 'wallPass' | 'bombPass' | 'flamePass' | 'detonator' @@ -116,6 +133,7 @@ export type Drop = { pos: Vec; kind: 'coins'; amount: number } | { pos: Vec; kin export interface GameState { seed: number + options: RunOptions level: number /** Turns taken on this attempt at this level, starting at 0. */ turn: number @@ -155,6 +173,8 @@ 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: { 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. */ aiRng: number @@ -197,3 +217,5 @@ export type GameEvent = | { type: 'powerUpTaken'; powerUp: PowerUp } | { type: 'detonated'; pos: Vec } | { type: 'runWon'; turns: number; deaths: number } + | { type: 'cratesCrumbling'; count: number } + | { type: 'crateCrumbled'; pos: Vec } diff --git a/src/style.css b/src/style.css index 61e9f97..bb2d00f 100644 --- a/src/style.css +++ b/src/style.css @@ -85,6 +85,22 @@ img { margin-left: 10px; } +#hud .tag { + padding: 1px 6px; + border-radius: 6px; + background: #ffc83d; + color: var(--ink); + font: 11px 'Kenney Future', ui-rounded, system-ui, sans-serif; +} + +#hud .tag + .tag { + margin-left: 3px; +} + +#hud-modes:empty { + display: none; +} + #hud small.par { margin: 0 0 0 6px; font-size: 12px; @@ -257,16 +273,6 @@ img { font-weight: 800; } -#banner .credit { - font-size: 13px; - font-weight: 600; - opacity: 0.85; -} - -#banner .credit a { - color: inherit; -} - #banner button { min-height: 56px; padding: 0 32px; @@ -382,6 +388,8 @@ img { } #dialog-body { + max-height: calc(100vh - 260px); + overflow-y: auto; font-size: 15px; line-height: 1.4; } @@ -446,3 +454,67 @@ img { .ui-button img { height: 20px; } + +/* The new-run dialog's choices. */ +#dialog-body fieldset.choices { + display: grid; + gap: 8px; + margin: 0 0 10px; + padding: 8px 10px 10px; + border: 2px solid #d9cba8; + border-radius: 10px; + text-align: left; +} + +#dialog-body legend { + padding: 0 4px; + font: 13px 'Kenney Future', ui-rounded, system-ui, sans-serif; +} + +#dialog-body .choices label { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 14px; + line-height: 1.3; +} + +#dialog-body .choices input { + flex: none; + width: 20px; + height: 20px; + margin: 0; + accent-color: var(--accent); +} + +#dialog-body .choices b { + display: block; +} + +#dialog-body .warning { + color: #b3122e; + font-weight: 700; +} + +#dialog-body .mode { + font-size: 13px; + text-align: left; +} + +/* Lots of charges / abilities (Developer mode): five to a row, shorter, smaller icons. */ +#actions.many button.charge { + flex-basis: calc(20% - 7px); + min-height: 34px; + margin-top: 4px; + font-size: 11px; +} + +#actions.many button.charge .icon { + width: 20px; + height: 20px; +} + +#actions.many .count { + top: -8px; + font-size: 10px; +} diff --git a/src/ui/controls.ts b/src/ui/controls.ts index 53e3d8f..7897099 100644 --- a/src/ui/controls.ts +++ b/src/ui/controls.ts @@ -86,6 +86,7 @@ export function createControls(handlers: Handlers) { enemies: document.getElementById('hud-enemies')!, coins: document.getElementById('hud-coins')!, stats: document.getElementById('hud-stats')!, + modes: document.getElementById('hud-modes')!, } return { @@ -106,13 +107,28 @@ export function createControls(handlers: Handlers) { const charge = chargeOf(id) if (charge) { b.hidden = s.charges[charge] === 0 - b.querySelector('.count')!.textContent = `×${s.charges[charge]}` + // Developer mode never spends them. + b.querySelector('.count')!.textContent = s.options.dev ? '∞' : `×${s.charges[charge]}` } } + // Many second-row buttons (Developer mode has 13): pack them tighter so the board keeps its room. + const extra = [...buttons.values()].filter((b) => b.classList.contains('charge') && !b.hidden).length + bar.classList.toggle('many', extra > 8) + // Lives lost from the starting three show as empty hearts; extra lives add full ones. const hearts = Array.from({ length: Math.max(s.lives, START_LIVES) }, (_, i) => icon(i < s.lives ? 'hud_heart' : 'hud_heart_empty'), ) + // 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) + hud.modes.innerHTML = tags.length + ? item('modes', tags.map((t) => `<span class="tag">${t}</span>`).join('')) + : '' hud.lives.innerHTML = item('lives', `<span class="sr-only">${s.lives} lives</span>${hearts.join('')}`) hud.level.innerHTML = item('level', icon('flag_green_a') + number(s.level, 'Level ')) hud.enemies.innerHTML = item('enemies', icon('slime_normal_rest') + number(s.enemies.length, 'Enemies left ')) diff --git a/src/ui/info.ts b/src/ui/info.ts index c584a52..34a7044 100644 --- a/src/ui/info.ts +++ b/src/ui/info.ts @@ -1,10 +1,10 @@ import { DECOY_TURNS, TELEPORT_RANGE } from '../sim/actions' -import { LAST_LEVEL } from '../sim/levelgen' +import { lastLevel } from '../sim/levelgen' import { bombAt, cellAt, eq, exitOpen, isBurning, PAR_BONUS } from '../sim/state' import { COIN_BAG, EXIT_SPAWN, STAR_TURNS } from '../sim/turn' import type { ChargeId, Dir, EnemyKind, GameState, Powers, PowerUpKind, Vec } from '../sim/types' import { dirBetween } from '../sim/enemies' -import { CHARGE_ICONS, coinSprite, ENEMY_SPRITES, POWER_UP_ICONS, spriteUrl } from '../render/sprites' +import { CHARGE_ICONS, coinSprite, ENEMY_SPRITES, POWER_UP_ICONS, spriteUrl, wallSprite } from '../render/sprites' /** * What the game says about each thing on the board and in the top bar, for the info dialog @@ -56,7 +56,16 @@ const ARROWS: Record<Dir, string> = { up: '↑', down: '↓', left: '←', right const PUNISH = `Don’t blast it once it’s uncovered: flame on it lets out ${EXIT_SPAWN} more enemies.` /** The top bar's items. */ -export type HudItem = 'lives' | 'level' | 'enemies' | 'coins' | 'turn' | 'bombs' | 'fire' | 'fuse' | 'star' | keyof Powers +export type HudItem = 'modes' | 'lives' | 'level' | 'enemies' | 'coins' | 'turn' | 'bombs' | 'fire' | 'fuse' | 'star' | keyof Powers + +/** One line per setting of the run that differs from a plain normal run. */ +export function modeLines(s: GameState): string[] { + const lines = [s.options.size === 'small' ? `Small board: ${s.width} × ${s.height}, ${lastLevel('small')} levels, fewer enemies and crates.` : `Normal board: ${s.width} × ${s.height}, ${lastLevel('normal')} levels.`] + if (s.options.dev) lines.push('Developer mode: every power-up and ability, unlimited action charges, 5 bombs, blast 5. You can still be killed.') + if (s.options.god) lines.push('God mode: nothing can kill you.') + if (s.options.crumble) lines.push('Crumbling crates: once the last enemy is gone, every crate crumbles away over 1–3 turns.') + return lines +} const PASS_KIND: Record<keyof Powers, PowerUpKind> = { run: 'skates', detonate: 'detonator', wallPass: 'wallPass', bombPass: 'bombPass', flamePass: 'flamePass', @@ -67,14 +76,20 @@ export function hudInfo(item: HudItem, s: GameState): Info { case 'lives': return { icon: 'hud_heart', - title: `Lives: ${s.lives}`, + title: s.options.god ? `Lives: ${s.lives} (God mode)` : `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.', - 'With no lives left, the run is over.', + s.options.god ? 'In God mode nothing can kill you, so you never lose one.' : 'With no lives left, the run is over.', ], } + case 'modes': + return { icon: 'lever', title: 'Run settings', body: modeLines(s) } case 'level': - return { icon: 'flag_green_a', title: `Level ${s.level} of ${LAST_LEVEL}`, body: [`Clear all ${LAST_LEVEL} levels to win the run. New enemy types join every few levels.`] } + 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.`], + } case 'enemies': return { icon: 'slime_normal_rest', @@ -166,7 +181,15 @@ export function tileInfo(s: GameState, p: Vec): Info | null { } } const cell = cellAt(s, p) - if (cell === 'wall') return { icon: 'block_blue', title: 'Wall', body: ['Indestructible: nothing gets through, not even flame.'] } + if (cell === 'wall') { + const icon = wallSprite(s.level, s.width, s.height, p) + const pillar = icon !== 'terrain_stone_block_center' + return { + icon, + title: pillar ? 'Pillar' : 'Outer wall', + body: [`Indestructible: nothing gets through, not even flame.${pillar ? ' Pillars change colour from level to level.' : ''}`], + } + } if (cell === 'block') { return { icon: 'block_planks', diff --git a/test/enemies.test.ts b/test/enemies.test.ts index b51bcef..cd6d4e6 100644 --- a/test/enemies.test.ts +++ b/test/enemies.test.ts @@ -287,6 +287,47 @@ describe('drops', () => { }) }) +describe('drops never share a tile', () => { + it('a second drop rolls to the nearest free floor tile', () => { + let both = false + for (let r = 1; r < 500 && !both; r++) { + const s = board(` + ######### + #P..1S..# + #########`) + s.dropRng = r + const { state } = step(s, { id: 'wait' }) + if (state.drops.length < 2) continue + both = true + const spots = state.drops.map((d) => `${d.pos.x},${d.pos.y}`) + expect(new Set(spots).size).toBe(spots.length) + for (const d of state.drops) expect(Math.abs(d.pos.x - 5)).toBeLessThanOrEqual(2) + } + expect(both).toBe(true) + }) + + it('a drop does not land where one already lies', () => { + let found = false + for (let r = 1; r < 500 && !found; r++) { + // The Blob dies in lingering flame, which (unlike a new blast) leaves the old drop alone. + const s = board(` + ######### + #P...B..# + #########`) + s.burning = [1 * s.width + 5] + s.drops.push({ pos: v(5, 1), kind: 'shield' }) + s.dropRng = r + const { state } = step(s, { id: 'wait' }) + expect(state.drops[0]).toEqual({ pos: v(5, 1), kind: 'shield' }) + const fresh = state.drops.slice(1) + if (!fresh.length) continue + found = true + for (const d of fresh) expect(d.pos).not.toEqual(v(5, 1)) + } + expect(found).toBe(true) + }) +}) + describe('the exit', () => { it('a block over the exit burns away and reveals it', () => { const s = board(` diff --git a/test/helpers.ts b/test/helpers.ts index b2d2783..b325bae 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,5 +1,5 @@ import { noCharges, noPowers, START_LIVES, START_STATS } from '../src/sim/state' -import type { Cell, EnemyKind, GameState, Stats, Vec } from '../src/sim/types' +import { DEFAULT_OPTIONS, type Cell, type EnemyKind, type GameState, type Stats, type Vec } from '../src/sim/types' const ENEMIES: Record<string, EnemyKind> = { B: 'blob', S: 'seeker', D: 'drifter' } @@ -17,11 +17,11 @@ export function board(picture: string, stats: Partial<Stats> = {}): GameState { const cells: Cell[] = [] let player: Vec | undefined const s: GameState = { - seed: 1, level: 1, turn: 5, width, height, cells, player: { x: 0, y: 0 }, + seed: 1, options: { ...DEFAULT_OPTIONS }, level: 1, turn: 5, width, height, cells, player: { x: 0, y: 0 }, lives: START_LIVES, stats: { ...START_STATS, ...stats }, bombs: [], status: 'playing', enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 100, coins: 0, charges: noCharges(), shield: false, frozen: false, decoy: null, burning: [], - powers: noPowers(), star: 0, powerUp: null, runTurns: 0, deaths: 0, aiRng: 1, dropRng: 1, + powers: noPowers(), star: 0, powerUp: null, runTurns: 0, deaths: 0, crumbling: [], aiRng: 1, dropRng: 1, } rows.forEach((row, y) => { if (row.length !== width) throw new Error(`row ${y} is ${row.length} wide, not ${width}`) diff --git a/test/options.test.ts b/test/options.test.ts new file mode 100644 index 0000000..1f942f1 --- /dev/null +++ b/test/options.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest' +import { enemyCount, generateLevel, lastLevel, SIZES, START } from '../src/sim/levelgen' +import { newRecord, replay } from '../src/sim/record' +import { DEV_STATS, newRun } from '../src/sim/state' +import { 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', () => { + 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') + 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')) + 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] + while (queue.length) { + const p = queue.pop()! + for (const q of [{ x: p.x + 1, y: p.y }, { x: p.x - 1, y: p.y }, { x: p.x, y: p.y + 1 }, { x: p.x, y: p.y - 1 }]) { + if (at(q) !== 'wall' && !seen.has(`${q.x},${q.y}`)) { + seen.add(`${q.x},${q.y}`) + queue.push(q) + } + } + } + expect(seen.size).toBe(g.cells.filter((c) => c !== 'wall').length) + }) + } + + 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('clearing level 20 wins a small run', () => { + const s = { ...board(` + #### + #PE# + ####`), level: 20, options: SMALL } + expect(step(s, { id: 'move', target: v(2, 1) }).state.status).toBe('won') + }) + + it('the next level is small too', () => { + 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]) + }) +}) + +describe('developer mode', () => { + const dev = { ...DEFAULT_OPTIONS, dev: true } + + it('has every ability, the developer stats and unlimited charges', () => { + const s = newRun(1, 1, 0, dev) + expect(Object.values(s.powers).every(Boolean)).toBe(true) + expect(s.stats).toEqual(DEV_STATS) + const before = s.charges.freeze + expect(step(s, { id: 'freeze' }).state.charges.freeze).toBe(before) + }) + + it('can still be killed by an enemy, and gets everything back after dying', () => { + const s = { ...board(` + ##### + #PB.# + #####`), options: dev } + const dead = step(s, { id: 'move', target: v(2, 1) }).state + expect(dead.status).toBe('dead') + const again = step(dead, { id: 'continue' }).state + expect(Object.values(again.powers).every(Boolean)).toBe(true) + }) +}) + +describe('god mode', () => { + it('nothing kills the player', () => { + const god = { ...DEFAULT_OPTIONS, god: true } + const flame = { ...board(` + ##### + #P1.# + #####`), options: god } + expect(step(flame, { id: 'wait' }).state.status).toBe('playing') + const enemy = { ...board(` + ##### + #PB.# + #####`), options: god } + expect(step(enemy, { id: 'move', target: v(2, 1) }).state.status).toBe('playing') + }) +}) + +describe('crumbling crates', () => { + const crumbly = { ...DEFAULT_OPTIONS, crumble: true } + + 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 + expect(s.crumbling.length).toBe(s.cells.filter((c) => c === 'block').length) + const events = [] + for (let t = 0; t < 3; t++) { + const next = step(s, { id: 'wait' }) + events.push(...next.events) + s = next.state + } + expect(s.cells.filter((c) => c === 'block')).toEqual([]) + expect(events.map((e) => e.type)).toContain('exitRevealed') + expect(s.enemies).toEqual([]) + }) + + it('without the option, crates stay', () => { + const s = board(` + ######## + #P...1B# + #+###### + ########`) + const r = step(s, { id: 'wait' }).state + 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 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 }]) { + record.actions.push(a) + s = step(s, a).state + } + expect(replay(structuredClone(record))).toEqual(s) + }) +}) diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index 1c51cc6..801799f 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -72,6 +72,11 @@ const SHOTS = [ query: 'seed=55', act: press('KeyB', 'KeyS', 'KeyD', 'KeyD', 'Space', 'KeyA', 'KeyA', 'KeyS', 'KeyS', 'KeyR'), }, + // A small board fits the screen whole: no scrolling. + { name: 'small', query: 'small', act: async () => {} }, + { name: 'small-desktop', viewport: DESKTOP, query: 'small', act: async () => {} }, + // Developer mode: every ability and unlimited charges (the ∞ badges); its tag in the top bar. + { name: 'dev', query: 'dev', act: async () => {} }, // Tapping an enemy, or anything in the top bar, explains it. { name: 'info-enemy', act: tap(4, 5) }, { name: 'info-hud', act: async (page) => { await page.click('[data-info=fuse]') } },