explodorama / commit
Power-ups, Run and Detonate, the 50-level ending, mid-run save (M6 part 1)
PLAN.md | 19 +++- README.md | 48 ++++++-- assets/kenney/bush.png | Bin 0 -> 2145 bytes assets/kenney/ladder_top.png | Bin 0 -> 589 bytes assets/kenney/lever.png | Bin 0 -> 1803 bytes assets/kenney/torch_off.png | Bin 0 -> 1331 bytes decisions.md | 20 ++++ docs/screenshots/auto-move.png | Bin 239199 -> 243724 bytes docs/screenshots/bomb-picked.png | Bin 238931 -> 243572 bytes docs/screenshots/charges.png | Bin 269004 -> 273479 bytes docs/screenshots/countdown.png | Bin 244028 -> 248462 bytes docs/screenshots/death.png | Bin 199288 -> 202732 bytes docs/screenshots/desktop.png | Bin 585942 -> 589981 bytes docs/screenshots/enemies.png | Bin 250386 -> 254864 bytes docs/screenshots/lingering.png | Bin 248179 -> 252768 bytes docs/screenshots/long-press.png | Bin 242461 -> 246984 bytes docs/screenshots/pierce-decoy.png | Bin 277755 -> 282258 bytes docs/screenshots/power-up.png | Bin 0 -> 260047 bytes docs/screenshots/run.png | Bin 0 -> 243745 bytes docs/screenshots/scrolled.png | Bin 253258 -> 257350 bytes docs/screenshots/start.png | Bin 237840 -> 242525 bytes src/main.ts | 49 +++++++- src/render/board.ts | 12 +- src/render/sprites.ts | 28 ++++- src/sim/actions.ts | 35 +++++- src/sim/levelgen.ts | 37 +++++- src/sim/path.ts | 11 +- src/sim/record.ts | 2 +- src/sim/state.ts | 43 +++++-- src/sim/turn.ts | 98 ++++++++++++---- src/sim/types.ts | 42 ++++++- src/style.css | 6 + src/ui/controls.ts | 43 +++++-- test/helpers.ts | 5 +- test/powerups.test.ts | 230 ++++++++++++++++++++++++++++++++++++++ tools/screenshots.mjs | 11 +- 36 files changed, 664 insertions(+), 75 deletions(-) diff --git a/PLAN.md b/PLAN.md index f7fae95..9543248 100644 --- a/PLAN.md +++ b/PLAN.md @@ -3,7 +3,7 @@ A turn-based, mobile-first, roguelike take on the 1985 NES *Bomberman*: 50 levels and an ending. Built with **LittleJS + TypeScript + Vite**, tested with **Vitest**, deployed to **GitLab Pages**. -Status: **M0–M5 done** (see §18); M6 next. This document is the design and the hand-off +Status: **M0–M5 done, M6 under way** (see §18). This document is the design and the hand-off for whoever continues the implementation. Read it fully before writing code, especially §1 (the turn), §15 (notes for the implementer) and §18 (milestones). Reasons for decisions are in `decisions.md`. @@ -194,6 +194,12 @@ NES rules kept (they create the classic tension): - **Flaming the exit door** spawns a group of enemies from it. - **The exit opens only when all enemies are dead.** - **Skates are guaranteed on level 1 or 2** so every player learns Run early. +- *Built (2026-10-20):* the pickup is under any crate except the exit's. Draw weights (to tune): + Bomb+ 20, Fire+ 20, Fuse+ 6, Coin bag 12 (10 coins), Heart 4, Wall Pass 8, Bomb Pass 8, + Flame Pass 5, Detonator 9, Star 8 (8 turns). Skates are not in the draw; which of level 1 or 2 + gets them comes from the seed. Sprites: gems for Bomb+ (blue), Fire+ (red), Fuse+ (yellow), + Skates (green), §13. Until Keep / Fresh exists, a death keeps stats and Skates (as "Keep" + would). A Run collects what is on the tile it passes through. --- @@ -350,7 +356,9 @@ contact. Intent shown every turn. Adapted from the NES eight: - **Ending** after level 50: an ending screen, then the **victory certificate**. - **Mid-run save is required**, not optional: a 50-level run won't happen in one sitting on a phone. The save is just the run record (seed + Workshop loadout + action log, §12), replayed on - load — or a JSON snapshot of the state plus the log so far. + load — or a JSON snapshot of the state plus the log so far. *Built (2026-10-20):* the record, + in `localStorage`, after every action, replayed on load; only for the same `SIM_VERSION`, and + never for test URLs (`?seed`, `?level`, `?charges`). Finished runs aren't resumed. ### Victory certificate @@ -431,7 +439,7 @@ Sprite assignments (✅ = drawn now; the rest are copied and waiting for their f | ✅ decoy | `character_green_front`, faded | | ✅ auto-move flag | `flag_green_a` | | ✅ intent arrows | drawn red lines with a head, not sprites | -| pickups (power-ups) | gems: `gem_red` Fire +1, `gem_blue` Bomb +1, `gem_yellow` Fuse +1, `gem_green` Skates (suggested; final mapping when M6 is built); `heart` Heart; `star` Star | +| ✅ power-ups | `gem_blue` Bomb+, `gem_red` Fire+, `gem_yellow` Fuse+, `gem_green` Skates (and the Run button), `lever` Detonator (and Detonate), `bush` Wall Pass, `ladder_top` Bomb Pass, `torch_off` Flame Pass, `star` Star (and a gold ring), `heart` Heart, `hud_coin` Coin bag; on a yellow glow. HUD: `torch_on_a` fuse, small pass icons. | | ✅ Blob | `slime_normal_rest` (nowhere to go), `_walk_a` / `_walk_b` alternating; mirrored when heading right. `_flat` spare for a death animation. | | ✅ Seeker | `fly_rest`, `fly_a`, `fly_b`, as the Blob | | ✅ Drifter | `saw_rest`, `saw_a`, `saw_b`; spins while moving | @@ -588,8 +596,9 @@ tools/ icons.mjs (PWA icons), screenshots.mjs (Playwright) charge drops, coins, charge inventory (buttons with counts), and all 11 charge actions (2026-10-20). Touch shortcuts from the user's playtest: tap self = Wait, hold = Bomb, tap far = auto-move (§3.1). -7. **M6 – Run loop:** NES pickups, coins, shop after each level, Keep/Fresh death choice, curses - from level 2, 50-level progression and ending, mid-run save. +7. **M6 – Run loop:** ✅ NES pickups (all 11, one hidden per level, Skates on level 1 or 2), + Run and Detonate, the passes and Star, 50-level progression and ending, mid-run save + (2026-10-20). ⏳ Shop after each level, Keep/Fresh death choice, curses from level 2. 8. **M7 – Look & feel:** animations, sounds (Kenney pack, §13), PWA icons, remaining enemies and actions, screenshots tool. 9. **M8 – Permanence:** Workshop, save export/import, victory certificate + QR, replay file and diff --git a/README.md b/README.md index 002899c..150e9f1 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ A turn-based, mobile-first, roguelike take on the 1985 NES bomb-laying classic. counts down with *your* moves, so nothing happens until you act — and you can see exactly what will burn before you commit. -Status: **early development.** The board, moving, bombs, chain reactions, lives, the first three -enemy types, coins, all eleven action charges and the exit to the next level work. Power-ups, the shop, -more enemies and the 50-level ending are planned — see [PLAN.md](PLAN.md). +Status: **early development.** The board, bombs, lives, the first three enemy types, coins, all +eleven action charges, hidden power-ups, the exit, the 50-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).  @@ -121,15 +122,47 @@ open, so pick them up soon. Coins will buy upgrades in the shop (not built yet). - **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. +### Power-ups + +Every level hides **one power-up** under a crate (never the exit's). Burn the crate to reveal it +— it sits on a yellow glow — then walk onto it. **Don't blast it once it's uncovered:** flame on +it destroys it and lets out 3 enemies, just like the exit. **Skates are always on level 1 or 2.** + +| Power-up | Looks like | Effect | Lost when you die? | +|---|---|---|---| +| **Bomb +1** | blue gem | One more bomb on the board at a time. | no | +| **Fire +1** | red gem | Blast reaches one tile further. | no | +| **Fuse +1** | yellow gem | Your bombs start one higher (more time to get away). | no | +| **Skates** | green gem | The **Run** action: 2 tiles in a straight line in one turn (**R**, then a direction). Both tiles must be empty; an enemy on the tile in between is run into. Anything on that tile is picked up. | no | +| **Detonator** | lever | The **Detonate** action: pick one of your bombs (**X**, then tap it); it goes off this turn. | yes | +| **Wall Pass** | bush | Walk through crates. | yes | +| **Bomb Pass** | ladder | Walk over bombs. | yes | +| **Flame Pass** | unlit torch | Flame doesn't hurt you (enemies still do). | yes | +| **Star** | star | Nothing hurts you for 8 turns (a gold ring; the top bar counts down). | yes | +| **Heart** | heart | One more life. | — | +| **Coin bag** | coin | 10 coins. | — | + +Run and Detonate appear as buttons in the second row once you have them. The top bar shows +your fuse and a small icon for each pass you hold. + ### Lives You have **3 lives**. Caught in a blast or touched by an enemy, you lose one and the level starts -again with the same layout and enemies. You keep your coins and charges. With none left, the run -is over and the seed is shown, so the same board can be replayed. +again with the same layout and enemies. You keep your coins, charges, stats and Skates, but lose +Wall Pass, Bomb Pass, Flame Pass, the Detonator and a Star (as in the original); the banner says +what you lost. With none left, the run is over and the seed is shown, so the same board can be +replayed. + +### The run + +**50 levels.** Clearing level 50 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. ### The top bar -Lives (hearts), level (flag), enemies left, coins, turn / par, and your bomb limit and blast size. +Lives (hearts), level (flag), enemies left, coins, turn / par, your bomb limit, blast size and +fuse, then the passes you hold and a Star's countdown. ### The board @@ -149,7 +182,8 @@ 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. +out. 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/assets/kenney/bush.png b/assets/kenney/bush.png new file mode 100644 index 0000000..62fed53 Binary files /dev/null and b/assets/kenney/bush.png differ diff --git a/assets/kenney/ladder_top.png b/assets/kenney/ladder_top.png new file mode 100644 index 0000000..c9fd03a Binary files /dev/null and b/assets/kenney/ladder_top.png differ diff --git a/assets/kenney/lever.png b/assets/kenney/lever.png new file mode 100644 index 0000000..f28552d Binary files /dev/null and b/assets/kenney/lever.png differ diff --git a/assets/kenney/torch_off.png b/assets/kenney/torch_off.png new file mode 100644 index 0000000..8c54d19 Binary files /dev/null and b/assets/kenney/torch_off.png differ diff --git a/decisions.md b/decisions.md index a81d06a..bc4ef2d 100644 --- a/decisions.md +++ b/decisions.md @@ -156,3 +156,23 @@ fix, two parts: - Auto-move's safety check uses each enemy's next planned move. - Starting fire stays at 1, so Fire+ pickups (M6) still have room to matter. +### 2026-10-20 — M6 part 1: power-ups, the ending, mid-run save + +- **Interim death rule until Keep / Fresh:** a death keeps stats, Skates, coins and charges and + loses Wall Pass, Bomb Pass, Flame Pass, the Detonator and a Star — the NES rule plus "Keep". + Keep / Fresh (PLAN.md §9) will replace the stats/Skates part. +- **The power-up is under any crate but the exit's**, including near the start, so an early + Bomb+ or Fire+ is possible. **Skates are on level 1 or 2**, chosen by the seed, and not in the + random draw anywhere else (so there is never a useless second pair). +- **A flamed power-up spawns the same punishment as a flamed exit** (3 of the level's toughest + type, no drops). Lingering flame doesn't destroy it, like drops. +- **Run collects what is on the tile it passes through** (coins, drops, the power-up), since + running past a coin without taking it would feel like a bug. Running into an enemy there kills. +- **Star and Flame Pass:** Star blocks every hit for 8 turns (counting the one it is taken in); + Flame Pass blocks flame only. Neither kills enemies on contact. +- **Save = the run record** in `localStorage`, replayed on load. It is a few KB even for a long + run, and replay is exact because the sim is deterministic. Test URLs neither load nor save. +- **Ending:** clearing level 50 sets status `won`; the banner shows total turns, lives lost and + the seed. The victory certificate is M8. +- `SIM_VERSION` is now 4: older saved runs are not resumed (they would replay differently). + diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png index dd0735b..a61e0b0 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 a760034..76cc335 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 4ef5d94..ba0bdc6 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 8e78746..0077429 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 d4adf48..44cc235 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 ebcea15..3a81306 100644 Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png index 7eba8cf..a2587b4 100644 Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png index 9e1053e..4a9396d 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 443c5c0..3d9c8f0 100644 Binary files a/docs/screenshots/long-press.png and b/docs/screenshots/long-press.png differ diff --git a/docs/screenshots/pierce-decoy.png b/docs/screenshots/pierce-decoy.png index 4ce5d41..8b03aaf 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 new file mode 100644 index 0000000..c3c4fcf Binary files /dev/null and b/docs/screenshots/power-up.png differ diff --git a/docs/screenshots/run.png b/docs/screenshots/run.png new file mode 100644 index 0000000..51a6d45 Binary files /dev/null and b/docs/screenshots/run.png differ diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png index b3585c8..01a738b 100644 Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png index 68bac0c..bbab4ec 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 7b368ce..9f80ba6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,7 +17,7 @@ import { isAvailable, isValidTarget, ACTIONS } from './sim/actions' import { enemyAt } from './sim/enemies' import { playerPath } from './sim/path' import { predictDanger, type Danger } from './sim/danger' -import { newRecord, type RunRecord } from './sim/record' +import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record' import { eq, idx, inBounds, newRun } from './sim/state' import { step } from './sim/turn' import { DIRS, type Action, type ActionId, type Dir, type GameState, type Vec } from './sim/types' @@ -50,9 +50,45 @@ const LONG_PRESS_MS = 450 function startRun(seed: number): void { record = newRecord(seed, startLevel, startCharges) state = newRun(seed, startLevel, startCharges) + save() afterChange() } +/** + * 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`) + * neither loads nor saves, so trying something never replaces the real run. Storage can be + * 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') + +function save(): void { + if (testing) return + try { + localStorage.setItem(SAVE_KEY, JSON.stringify(record)) + } catch { + // No storage: play on without saving. + } +} + +/** The saved run, if there is one from this version of the rules that is still going. */ +function resumeSaved(): boolean { + if (testing) return false + try { + const saved = JSON.parse(localStorage.getItem(SAVE_KEY) ?? 'null') as RunRecord | null + if (!saved || saved.simVersion !== SIM_VERSION) return false + const s = replay(saved) + if (s.status === 'gameOver' || s.status === 'won') return false + record = saved + state = s + afterChange() + return true + } catch { + return false + } +} + function randomSeed(): number { return Math.floor(Math.random() * 2 ** 31) } @@ -68,6 +104,7 @@ function act(action: Action): void { if (e.type === 'moved' && e.to.x !== e.from.x) facingLeft = e.to.x < e.from.x } selected = null + save() afterChange() } @@ -137,12 +174,12 @@ function pick(id: ActionId): void { /** * A direction key: uses the picked action that way (a bomb or kick next to the player, a throw - * 2 tiles away), or moves if nothing is picked. + * or run 2 tiles away), or moves if nothing is picked. */ function direction(d: Dir): void { if (state.status !== 'playing') return const id: ActionId = selected && ACTIONS[selected].needsTarget ? selected : 'move' - const reach = id === 'throw' ? 2 : 1 + const reach = id === 'throw' || id === 'run' ? 2 : 1 const p = { x: state.player.x + reach * DIRS[d].x, y: state.player.y + reach * DIRS[d].y } if (isValidTarget(state, id, p)) act({ id, target: p }) } @@ -151,7 +188,7 @@ const controls = createControls({ pick, banner() { if (state.status === 'dead' || state.status === 'cleared') act({ id: 'continue' }) - else if (state.status === 'gameOver') startRun(randomSeed()) + else if (state.status === 'gameOver' || state.status === 'won') startRun(randomSeed()) }, }) @@ -163,7 +200,7 @@ const KEY_DIRS: Record<string, Dir> = { } const ACTION_KEYS: Record<string, ActionId> = { - KeyB: 'bomb', KeyK: 'kick', KeyT: 'throw', KeyH: 'shield', KeyF: 'freeze', Equal: 'fuseUp', Minus: 'fuseDown', + KeyB: 'bomb', KeyR: 'run', KeyX: 'detonate', KeyK: 'kick', KeyT: 'throw', KeyH: 'shield', KeyF: 'freeze', Equal: 'fuseUp', Minus: 'fuseDown', KeyG: 'diagonal', KeyP: 'teleport', KeyI: 'pierce', KeyO: 'power', KeyL: 'line', KeyY: 'decoy', } @@ -205,7 +242,7 @@ function tileUnder(e: PointerEvent): Vec | null { return inBounds(state, p) ? p : null } -startRun(Number(params.get('seed')) || randomSeed()) +if (!resumeSaved()) startRun(Number(params.get('seed')) || randomSeed()) // Sharp on high-density screens, capped so phones don't pay for 3×. setCanvasPixelRatio(Math.min(devicePixelRatio, 2)) diff --git a/src/render/board.ts b/src/render/board.ts index d51af79..b00ca2f 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, sprite, type BoardSprite } from './sprites' +import { CHARGE_ICONS, coinSprite, digitSprite, ENEMY_SPRITES, POWER_UP_ICONS, sprite, type BoardSprite } from './sprites' /** * The one place where sim tiles meet LittleJS world space. The sim's y grows downward @@ -62,6 +62,8 @@ const C = { clear: rgb(0, 0, 0, 0), pathDot: rgb(0.25, 0.62, 0.87, 0.7), burning: rgb(1, 0.55, 0.1, 0.5), + powerUpGlow: rgb(1, 0.95, 0.55, 0.8), + star: rgb(1, 0.85, 0.2, 0.9), blocked: rgb(0.85, 0.2, 0.2, 0.35), } @@ -90,6 +92,12 @@ export function drawBoard(view: View): void { } } if (cellAt(s, s.exit) === 'floor') drawExit(toWorld(s, s.exit), exitOpen(s)) + // The level's power-up, once its crate is gone: on a soft glow, so it stands out from drops. + if (s.powerUp && cellAt(s, s.powerUp.pos) === 'floor') { + const pos = toWorld(s, s.powerUp.pos) + drawCircle(pos, 0.86, C.powerUpGlow) + drawTile(pos.add(vec2(0, 0.04 * Math.sin(time * 3))), vec2(0.7), sprite(POWER_UP_ICONS[s.powerUp.kind])) + } for (const i of danger.next) if (!danger.now.has(i)) drawRect(toWorld(s, tileOf(s, i)), vec2(0.94), C.dangerNext) for (const i of danger.now) drawRect(toWorld(s, tileOf(s, i)), vec2(0.94), C.dangerNow) @@ -153,6 +161,8 @@ export function drawBoard(view: View): void { for (const e of s.enemies) drawEnemy(s, e) if (s.shield) drawCircle(toWorld(s, s.player), 1.08, C.clear, 0.07, C.shield) + // A Star: a pulsing gold ring for as long as it lasts. + if (s.star > 0) drawCircle(toWorld(s, s.player), 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) } diff --git a/src/render/sprites.ts b/src/render/sprites.ts index 5c474e8..52c3939 100644 --- a/src/render/sprites.ts +++ b/src/render/sprites.ts @@ -1,5 +1,5 @@ import { tile, vec2, type TileInfo } from 'littlejsengine' -import type { ChargeId, EnemyKind } from '../sim/types' +import type { ChargeId, EnemyKind, PowerUpKind } from '../sim/types' /** * Art from Kenney's New Platformer Pack 1.1 (CC0), copied into `assets/kenney/` under the pack's @@ -57,6 +57,17 @@ const BOARD = [ 'bridge_logs', 'hud_player_helmet_green', 'character_green_front', + 'gem_blue', + 'gem_red', + 'gem_yellow', + 'gem_green', + 'heart', + 'star', + 'hud_coin', + 'bush', + 'ladder_top', + 'torch_off', + 'lever', 'slime_normal_rest', 'slime_normal_walk_a', 'slime_normal_walk_b', @@ -116,3 +127,18 @@ export const ENEMY_SPRITES: Record<EnemyKind, { rest: BoardSprite; a: BoardSprit export function coinSprite(amount: number): BoardSprite { return amount >= 5 ? 'coin_gold' : amount >= 2 ? 'coin_silver' : 'coin_bronze' } + +/** Each power-up's sprite (PLAN.md §13): gems for the stats and Skates, the rest by meaning. */ +export const POWER_UP_ICONS: Record<PowerUpKind, BoardSprite> = { + bombPlus: 'gem_blue', + firePlus: 'gem_red', + fusePlus: 'gem_yellow', + skates: 'gem_green', + wallPass: 'bush', + bombPass: 'ladder_top', + flamePass: 'torch_off', + detonator: 'lever', + star: 'star', + coinBag: 'hud_coin', + heart: 'heart', +} diff --git a/src/sim/actions.ts b/src/sim/actions.ts index 9dfad12..2cd8af5 100644 --- a/src/sim/actions.ts +++ b/src/sim/actions.ts @@ -1,6 +1,6 @@ import { enemyAt } from './enemies' import { add, bombAt, eq, isBurning, isFree, playerCanEnter } from './state' -import { DIRS, DIR_LIST, type ActionId, type Bomb, type ChargeId, type GameEvent, type GameState, type Vec } from './types' +import { DIRS, DIR_LIST, type ActionId, type Bomb, type ChargeId, type GameEvent, type GameState, type Powers, type Vec } from './types' /** * The board actions (PLAN.md §3). Adding one = an entry here, its tests, and a button. @@ -13,6 +13,8 @@ export interface ActionDef { needsTarget: boolean /** The action charge it spends (PLAN.md §3.3); only offered while the player holds one. */ charge?: ChargeId + /** The power-up ability it needs (PLAN.md §3.2); only offered once the player has it. */ + ability?: keyof Powers validTargets(s: GameState): Vec[] apply(s: GameState, target: Vec | undefined, events: GameEvent[]): void } @@ -89,6 +91,33 @@ export const ACTIONS: Record<ActionId, ActionDef> = { events.push({ type: 'waited' }) }, }, + run: { + id: 'run', + label: 'Run', + needsTarget: true, + ability: 'run', + // 2 tiles in a straight line; both must be free, so nothing is jumped over. An enemy on the + // first tile is run into (step() checks it). + validTargets: (s) => + DIR_LIST.map((d) => DIRS[d]).flatMap((d) => { + const mid = add(s.player, d) + const land = add(mid, d) + return playerCanEnter(s, mid) && playerCanEnter(s, land) ? [land] : [] + }), + apply: (s, target, events) => walkTo(s, target!, events), + }, + detonate: { + id: 'detonate', + label: 'Detonate', + needsTarget: true, + ability: 'detonate', + // Any of your bombs: it explodes this turn, in phase 3, with everything it sets off. + validTargets: (s) => s.bombs.filter((b) => b.timer > 0).map((b) => b.pos), + apply(s, target, events) { + bombAt(s, target!)!.timer = 0 + events.push({ type: 'detonated', pos: { ...target! } }) + }, + }, kick: { id: 'kick', label: 'Kick', @@ -238,7 +267,7 @@ function changeFuse(s: GameState, at: Vec, delta: number, events: GameEvent[]): } export const ACTION_ORDER: readonly ActionId[] = [ - 'move', 'bomb', 'wait', 'kick', 'throw', 'shield', 'freeze', 'fuseUp', 'fuseDown', 'diagonal', 'teleport', + 'move', 'bomb', 'wait', 'run', 'detonate', 'kick', 'throw', 'shield', 'freeze', 'fuseUp', 'fuseDown', 'diagonal', 'teleport', 'pierce', 'power', 'line', 'decoy', ] @@ -254,6 +283,8 @@ export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean /** Whether the action can be picked right now. */ export function isAvailable(s: GameState, id: ActionId): boolean { if (s.status !== 'playing') return false + const ability = ACTIONS[id].ability + if (ability && !s.powers[ability]) return false const charge = chargeOf(id) if (charge) { if (s.charges[charge] <= 0) return false diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts index 3c48cdf..79c6d4f 100644 --- a/src/sim/levelgen.ts +++ b/src/sim/levelgen.ts @@ -1,5 +1,5 @@ -import { stream } from './rng' -import { DIR_LIST, type Cell, type Dir, type EnemyKind, type Vec } from './types' +import { hashSeed, stream, type Rng } from './rng' +import { DIR_LIST, 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 @@ -29,6 +29,30 @@ export function parFor(enemies: number): number { return 50 + 10 * enemies } +/** The run's last level; clearing it wins (PLAN.md §11). */ +export const LAST_LEVEL = 50 + +/** + * How likely each power-up is to be a level's hidden one. Skates aren't in the draw: they are + * guaranteed on level 1 or 2 instead (which one, from the seed), so every run gets Run early. + * To tune by playtesting. + */ +const POWER_UP_WEIGHTS: [PowerUpKind, number][] = [ + ['bombPlus', 20], ['firePlus', 20], ['fusePlus', 6], ['coinBag', 12], ['heart', 4], + ['wallPass', 8], ['bombPass', 8], ['flamePass', 5], ['detonator', 9], ['star', 8], +] + +export function skatesLevel(seed: number): number { + return 1 + (hashSeed(seed, 'skates') & 1) +} + +function pickPowerUp(seed: number, level: number, rng: Rng): PowerUpKind { + if (level === skatesLevel(seed)) return 'skates' + let r = rng.next() * POWER_UP_WEIGHTS.reduce((sum, [, w]) => sum + w, 0) + for (const [kind, w] of POWER_UP_WEIGHTS) if ((r -= w) < 0) return kind + return POWER_UP_WEIGHTS[0]![0] +} + export interface EnemyStart { kind: EnemyKind pos: Vec @@ -42,6 +66,8 @@ export interface Layout { start: Vec /** Always under a soft block. */ exit: Vec + /** Under a different soft block. */ + powerUp: PowerUp enemies: EnemyStart[] par: number } @@ -71,7 +97,7 @@ export function generateLevel(seed: number, level: number): Layout { 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) }) const blocks = cells.flatMap((c, i) => (c === 'block' && far(i) ? [i] : [])) - const exit = at(blocks[rng.int(blocks.length)]!) + 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) @@ -80,7 +106,10 @@ export function generateLevel(seed: number, level: number): Layout { 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)]! }) } - return { width, height, cells, start: { ...START }, exit, enemies, par: parFor(enemies.length) } + // 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) } } function key(p: Vec): string { diff --git a/src/sim/path.ts b/src/sim/path.ts index ad3f456..fbc1854 100644 --- a/src/sim/path.ts +++ b/src/sim/path.ts @@ -1,14 +1,15 @@ -import { add, eq, isFree } from './state' +import { add, eq, playerCanPass } from './state' import { DIRS, DIR_LIST, type GameState, type Vec } from './types' /** - * The shortest walk for the player to `to` over free tiles (floor, no bomb), for auto-move. The - * result leaves out the player's own tile and ends at `to`; null if `to` can't be reached. + * The shortest walk for the player to `to`, for auto-move, over tiles the player can pass (free + * floor, plus crates or bombs with Wall or Bomb Pass). The result leaves out the player's own + * tile and ends at `to`; null if `to` can't be reached. * Enemies don't block the path, because they will have moved by the time the player gets there; * the UI refuses a step onto an enemy instead. */ export function playerPath(s: GameState, to: Vec): Vec[] | null { - if (eq(s.player, to) || !isFree(s, to)) return null + if (eq(s.player, to) || !playerCanPass(s, to)) return null const key = (p: Vec) => p.y * s.width + p.x const prev = new Map<number, Vec>([[key(s.player), s.player]]) const queue = [s.player] @@ -16,7 +17,7 @@ export function playerPath(s: GameState, to: Vec): Vec[] | null { const p = queue[i]! for (const d of DIR_LIST) { const q = add(p, DIRS[d]) - if (prev.has(key(q)) || !isFree(s, q)) continue + if (prev.has(key(q)) || !playerCanPass(s, q)) continue prev.set(key(q), p) if (eq(q, to)) { const path = [q] diff --git a/src/sim/record.ts b/src/sim/record.ts index dc582a5..53faefc 100644 --- a/src/sim/record.ts +++ b/src/sim/record.ts @@ -6,7 +6,7 @@ import type { Action, GameState } from './types' * Bumped whenever a rule change would make an old record replay differently. Records from * another version can't be trusted to replay (PLAN.md §11). */ -export const SIM_VERSION = 3 +export const SIM_VERSION = 4 /** 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 87b7d7e..08a3150 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 Stats, type Vec } from './types' +import { CHARGE_IDS, type Bomb, type Cell, type ChargeId, type GameState, type Powers, type Stats, type Vec } from './types' export const START_LIVES = 3 export const START_STATS: Stats = { capacity: 1, fire: 1, fuse: 3 } @@ -15,6 +15,14 @@ export interface Carry { stats: Stats coins: number charges: Record<ChargeId, number> + powers: Powers + star: number + runTurns: number + deaths: number +} + +export function noPowers(): Powers { + return { run: false, detonate: false, wallPass: false, bombPass: false, flamePass: false } } /** `n` of every action charge. */ @@ -28,15 +36,18 @@ export function noCharges(): Record<ChargeId, number> { /** `n`: start with this many of every action charge (a testing aid, `?charges=` in the URL). */ export function newRun(seed: number, level = 1, n = 0): GameState { - return createLevel(seed, level, { lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charges: charges(n) }) + return createLevel(seed, level, { + lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charges: charges(n), powers: noPowers(), star: 0, + runTurns: 0, deaths: 0, + }) } /** * A fresh attempt at a level: the layout and enemies come from the seed, so a retry after a death - * is the same board. Lives, stats, coins and charges come with the player. + * 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, enemies, par } = generateLevel(seed, level) + const { width, height, cells, start, exit, powerUp, enemies, par } = generateLevel(seed, level) const s: GameState = { seed, level, turn: 0, width, height, cells, player: start, lives: carry.lives, stats: { ...carry.stats }, coins: carry.coins, charges: { ...carry.charges }, @@ -44,14 +55,24 @@ export function createLevel(seed: number, level: number, carry: Carry): GameStat 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, aiRng: hashSeed(seed, 'ai', level), dropRng: hashSeed(seed, 'drops', level), } planIntents(s) return s } -export function carryOf(s: GameState): Carry { - return { lives: s.lives, stats: s.stats, coins: s.coins, charges: s.charges } +/** + * What the player takes on. After a death, the NES rule: Wall Pass, Bomb Pass, Flame Pass, + * Detonator and Star are lost; stats, Skates (Run), coins and charges are kept. (The Keep / Fresh + * choice of PLAN.md §9 will decide about the stats and Skates.) + */ +export function carryOf(s: GameState, died = false): Carry { + const powers = died ? { ...noPowers(), run: s.powers.run } : s.powers + return { + lives: s.lives, stats: s.stats, coins: s.coins, charges: s.charges, powers, star: died ? 0 : s.star, + runTurns: s.runTurns, deaths: s.deaths, + } } /** The exit can be used once its block is gone and every enemy is dead. */ @@ -98,7 +119,13 @@ export function isBurning(s: GameState, p: Vec): boolean { return inBounds(s, p) && s.burning.includes(idx(s, p)) } -/** Where the player can step or land: free and not on fire. */ +/** Where the player's body can go, fire aside: floor, or a soft block with Wall Pass; a bomb only with Bomb Pass. */ +export function playerCanPass(s: GameState, p: Vec): boolean { + const cell = cellAt(s, p) + return (cell === 'floor' || (cell === 'block' && s.powers.wallPass)) && (!bombAt(s, p) || s.powers.bombPass) +} + +/** Where the player can step or land now: passable, and not on fire (unless Flame Pass). */ export function playerCanEnter(s: GameState, p: Vec): boolean { - return isFree(s, p) && !isBurning(s, p) + return playerCanPass(s, p) && (!isBurning(s, p) || s.powers.flamePass) } diff --git a/src/sim/turn.ts b/src/sim/turn.ts index de845cc..8551e3c 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -1,17 +1,21 @@ import { ACTIONS, chargeOf, isAvailable, isValidTarget } from './actions' import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies' -import { enemyKinds } from './levelgen' +import { enemyKinds, LAST_LEVEL } from './levelgen' import { detonate, tickBombs } from './explosions' -import { carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, PAR_BONUS } from './state' -import type { Action, GameEvent, GameState } from './types' +import { add, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, PAR_BONUS } from './state' +import type { Action, GameEvent, GameState, PowerUpKind, Vec } from './types' export interface StepResult { state: GameState events: GameEvent[] } -/** How many enemies come out of an exit caught in a flame. */ +/** How many enemies come out of an exit or power-up caught in a flame. */ export const EXIT_SPAWN = 3 +/** Turns of invincibility from a Star. */ +export const STAR_TURNS = 8 +/** Coins in a Coin bag. */ +export const COIN_BAG = 10 /** * One turn (PLAN.md §1). Never mutates `prev`. Throws on an action that isn't allowed right @@ -24,7 +28,7 @@ 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)), events } + return { state: createLevel(s.seed, s.level, carryOf(s, true)), events } } if (s.status === 'cleared') { events.push({ type: 'levelStarted', level: s.level + 1 }) @@ -44,21 +48,30 @@ export function step(prev: GameState, action: Action): StepResult { s.charges[charge]-- events.push({ type: 'chargeUsed', id: charge }) } + const from = s.player def.apply(s, action.target, events) - pickUp(s, events) + // A run passes through the tile in between: anything there is picked up, or run into. + const passed: Vec | null = action.id === 'run' ? add(from, { x: (s.player.x - from.x) / 2, y: (s.player.y - from.y) / 2 }) : null + if (passed) pickUp(s, passed, events) + pickUp(s, s.player, events) if (exitOpen(s) && eq(s.player, s.exit)) { const parBonus = s.turn + 1 <= s.par ? PAR_BONUS : 0 s.coins += parBonus s.turn++ - s.status = 'cleared' + s.runTurns++ events.push({ type: 'levelCleared', level: s.level, turns: s.turn, parBonus }) + if (s.level >= LAST_LEVEL) { + s.status = 'won' + events.push({ type: 'runWon', turns: s.runTurns, deaths: s.deaths }) + } else s.status = 'cleared' return { state: s, events } } - // A shield absorbs every hit for the rest of the turn it breaks in. + // A shield absorbs every hit for the rest of the turn it breaks in. A Star stops every hit; + // Flame Pass stops flame. let shielded = false - const hurt = (): void => { - if (shielded) return + const hurt = (cause: 'flame' | 'enemy'): void => { + if (shielded || s.star > 0 || (cause === 'flame' && s.powers.flamePass)) return if (s.shield) { s.shield = false shielded = true @@ -66,22 +79,24 @@ export function step(prev: GameState, action: Action): StepResult { return } s.lives-- + s.deaths++ s.status = s.lives > 0 ? 'dead' : 'gameOver' events.push({ type: 'playerDied', pos: s.player, livesLeft: s.lives }) } - // Contact, first check: the player walked into an enemy (or swapped places with one). - if (enemyAt(s, s.player)) hurt() + // Contact, first check: the player walked (or ran) into an enemy, or swapped places with one. + if (enemyAt(s, s.player) || (passed && enemyAt(s, passed))) hurt('enemy') // Phase 2: enemies carry out their announced intents. Second contact check. if (s.status === 'playing') { moveEnemies(s, events) - if (enemyAt(s, s.player)) hurt() + if (enemyAt(s, s.player)) hurt('enemy') } // Phase 3: bombs tick; those at 0 explode, in chains. Last turn's flame is still burning too. const lingering = new Set(s.burning) const exitWasOpen = cellAt(s, s.exit) === 'floor' + const powerUpWasOpen = !!s.powerUp && cellAt(s, s.powerUp.pos) === 'floor' const enemiesBefore = s.enemies.length tickBombs(s, events) const burned = detonate(s, events) @@ -90,7 +105,7 @@ export function step(prev: GameState, action: Action): StepResult { // the decoy. Only new flame burns drops lying in the open; slain enemies then leave their own // drops, so those survive the flame that made them (and its lingering). const fire = new Set([...burned, ...lingering]) - if (s.status === 'playing' && fire.has(idx(s, s.player))) hurt() + if (s.status === 'playing' && fire.has(idx(s, s.player))) hurt('flame') for (const d of s.drops.filter((d) => burned.has(idx(s, d.pos)))) { s.drops.splice(s.drops.indexOf(d), 1) events.push({ type: 'dropBurned', drop: d }) @@ -109,35 +124,72 @@ export function step(prev: GameState, action: Action): StepResult { } if (!exitWasOpen && cellAt(s, s.exit) === 'floor') events.push({ type: 'exitRevealed', pos: s.exit }) // Flaming an uncovered exit lets out a group of the level's toughest type (NES rule, PLAN.md §5). - if (exitWasOpen && burned.has(idx(s, s.exit))) { - const kinds = enemyKinds(s.level) - for (let n = 0; n < EXIT_SPAWN; n++) { - s.enemies.push({ id: s.nextEnemyId++, kind: kinds[kinds.length - 1]!, pos: { ...s.exit }, dir: 'up', plan: [], spawned: true }) + if (exitWasOpen && burned.has(idx(s, s.exit))) spawnPunishment(s, s.exit, events) + // The same for the level's power-up: flame on it once uncovered destroys it and lets enemies out. + if (s.powerUp) { + const at = idx(s, s.powerUp.pos) + if (powerUpWasOpen && burned.has(at)) { + events.push({ type: 'powerUpBurned', powerUp: s.powerUp }) + spawnPunishment(s, s.powerUp.pos, events) + s.powerUp = null + } else if (!powerUpWasOpen && cellAt(s, s.powerUp.pos) === 'floor') { + events.push({ type: 'powerUpRevealed', powerUp: s.powerUp }) } - events.push({ type: 'enemiesSpawned', pos: s.exit, count: EXIT_SPAWN }) } if (enemiesBefore > 0 && s.enemies.length === 0) events.push({ type: 'exitOpened', pos: s.exit }) // This turn's flame lingers through the next turn; last turn's goes out. s.burning = [...burned] - // Phase 5: enemies announce their next intents. A decoy lasts a fixed number of these. + // Phase 5: enemies announce their next intents. A decoy lasts a fixed number of these; so does a Star. planIntents(s) + if (s.star > 0) s.star-- if (s.decoy && --s.decoy.turns <= 0) { events.push({ type: 'decoyGone', pos: s.decoy.pos, burned: false }) s.decoy = null } s.turn++ + s.runTurns++ return { state: s, events } } -/** Collect every drop on the player's tile. */ -function pickUp(s: GameState, events: GameEvent[]): void { - for (const d of s.drops.filter((d) => eq(d.pos, s.player))) { +/** 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) + for (let n = 0; n < EXIT_SPAWN; n++) { + s.enemies.push({ id: s.nextEnemyId++, kind: kinds[kinds.length - 1]!, pos: { ...pos }, dir: 'up', plan: [], spawned: true }) + } + events.push({ type: 'enemiesSpawned', pos: { ...pos }, count: EXIT_SPAWN }) +} + +/** Collect every drop at `p`, and the level's power-up if it lies there uncovered. */ +function pickUp(s: GameState, p: Vec, events: GameEvent[]): void { + for (const d of s.drops.filter((d) => eq(d.pos, p))) { s.drops.splice(s.drops.indexOf(d), 1) if (d.kind === 'coins') s.coins += d.amount else s.charges[d.kind]++ events.push({ type: 'pickedUp', drop: d }) } + if (s.powerUp && eq(s.powerUp.pos, p) && cellAt(s, p) === 'floor') { + applyPowerUp(s, s.powerUp.kind) + events.push({ type: 'powerUpTaken', powerUp: s.powerUp }) + s.powerUp = null + } +} + +function applyPowerUp(s: GameState, kind: PowerUpKind): void { + switch (kind) { + case 'bombPlus': s.stats.capacity++; break + case 'firePlus': s.stats.fire++; break + case 'fusePlus': s.stats.fuse++; break + case 'skates': s.powers.run = true; break + case 'detonator': s.powers.detonate = true; break + case 'wallPass': s.powers.wallPass = true; break + case 'bombPass': s.powers.bombPass = true; break + case 'flamePass': s.powers.flamePass = true; break + case 'star': s.star = STAR_TURNS; break + case 'coinBag': s.coins += COIN_BAG; break + case 'heart': s.lives++; break + } } diff --git a/src/sim/types.ts b/src/sim/types.ts index c6012b1..ebe23bf 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -46,8 +46,33 @@ export interface Stats { /** * `dead`: lost a life and waiting for `continue`. `gameOver`: no lives left. * `cleared`: stepped onto the open exit; `continue` goes to the next level. + * `won`: cleared the last level (PLAN.md §11); the run is over. */ -export type Status = 'playing' | 'dead' | 'gameOver' | 'cleared' +export type Status = 'playing' | 'dead' | 'gameOver' | 'cleared' | 'won' + +/** 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' + | 'star' | 'coinBag' | 'heart' + +export interface PowerUp { + pos: Vec + kind: PowerUpKind +} + +/** Abilities from power-ups. Run and Detonate are actions; the passes change where the player can go. */ +export interface Powers { + /** Skates: the Run action, 2 tiles in a straight line. Kept on death. */ + run: boolean + /** Detonator: the Detonate action, set off one of your bombs now. Lost on death. */ + detonate: boolean + /** Walk through soft blocks. Lost on death. */ + wallPass: boolean + /** Walk over bombs. Lost on death. */ + bombPass: boolean + /** Flame doesn't hurt. Lost on death. */ + flamePass: boolean +} /** The enemy types so far (PLAN.md §10). */ export type EnemyKind = 'blob' | 'seeker' | 'drifter' @@ -116,6 +141,14 @@ export interface GameState { shield: boolean /** Enemies skip their next move (Freeze). */ frozen: boolean + powers: Powers + /** Turns played and lives lost over the whole run, for the ending. */ + runTurns: number + deaths: number + /** Turns of Star invincibility left. */ + star: number + /** This level's power-up: hidden while its block stands, then lying in the open until taken. Null once taken or burned. */ + powerUp: PowerUp | null /** * Tile indices still on fire from last turn's explosions. Flame lingers one extra turn: it * kills creatures that are in it or walk into it this turn, and the player can't step or put @@ -129,7 +162,7 @@ export interface GameState { } /** The actions the player can pick on the board. */ -export type ActionId = 'move' | 'bomb' | 'wait' | ChargeActionId +export type ActionId = 'move' | 'bomb' | 'wait' | 'run' | 'detonate' | ChargeActionId export type Action = { id: ActionId; target?: Vec } | { id: 'continue' } @@ -159,3 +192,8 @@ export type GameEvent = | { type: 'fuseChanged'; pos: Vec; timer: number } | { type: 'decoyPlaced'; pos: Vec } | { type: 'decoyGone'; pos: Vec; burned: boolean } + | { type: 'powerUpRevealed'; powerUp: PowerUp } + | { type: 'powerUpBurned'; powerUp: PowerUp } + | { type: 'powerUpTaken'; powerUp: PowerUp } + | { type: 'detonated'; pos: Vec } + | { type: 'runWon'; turns: number; deaths: number } diff --git a/src/style.css b/src/style.css index 98005ad..2879045 100644 --- a/src/style.css +++ b/src/style.css @@ -65,6 +65,12 @@ img { filter: drop-shadow(0 0 1.5px rgb(255 255 255 / 0.7)); } +#hud .icon.small { + width: 22px; + height: 22px; + margin-left: 6px; +} + #hud .digit { width: 20px; height: 20px; diff --git a/src/ui/controls.ts b/src/ui/controls.ts index a040d03..1f1f3a8 100644 --- a/src/ui/controls.ts +++ b/src/ui/controls.ts @@ -1,25 +1,37 @@ import { ACTIONS, ACTION_ORDER, chargeOf, isAvailable } from '../sim/actions' import { PAR_BONUS, START_LIVES } from '../sim/state' -import type { ActionId, GameState } from '../sim/types' -import { CHARGE_ICONS, spriteUrl } from '../render/sprites' +import type { ActionId, GameState, PowerUpKind, Powers } from '../sim/types' +import { CHARGE_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites' /** The HTML around the board: HUD, action bar, and the banner shown between attempts and levels. */ export interface Handlers { /** An action button was pressed. */ pick(id: ActionId): void - /** The banner's button: continue after losing a life or clearing a level, or a new run after game over. */ + /** The banner's button: continue after losing a life or clearing a level, or a new run after game over or the ending. */ banner(): void } export const KEYS: Record<ActionId, string> = { - move: 'WASD', bomb: 'B', wait: 'Space', kick: 'K', throw: 'T', shield: 'H', freeze: 'F', fuseUp: '=', fuseDown: '−', + move: 'WASD', bomb: 'B', wait: 'Space', run: 'R', detonate: 'X', kick: 'K', throw: 'T', shield: 'H', freeze: 'F', fuseUp: '=', fuseDown: '−', diagonal: 'G', teleport: 'P', pierce: 'I', power: 'O', line: 'L', decoy: 'Y', } const ICONS: Record<ActionId, string> = { move: 'character_green_walk_a', bomb: 'bomb', wait: 'character_green_duck', ...CHARGE_ICONS, + run: POWER_UP_ICONS.skates, detonate: POWER_UP_ICONS.detonator, fuseUp: CHARGE_ICONS.fuse, fuseDown: CHARGE_ICONS.fuse, } +/** The passes shown in the HUD, with their power-up and name. */ +const PASSES: [keyof Powers, PowerUpKind, string][] = [ + ['run', 'skates', 'Skates'], + ['detonate', 'detonator', 'Detonator'], + ['wallPass', 'wallPass', 'Wall Pass'], + ['bombPass', 'bombPass', 'Bomb Pass'], + ['flamePass', 'flamePass', 'Flame Pass'], +] +/** What a death takes away (the NES rule; Skates are kept). */ +const LOST_ON_DEATH = PASSES.filter(([p]) => p !== 'run') + function icon(name: string, className = 'icon'): string { return `<img class="${className}" src="${spriteUrl(name)}" alt="" draggable="false">` } @@ -36,7 +48,8 @@ export function createControls(handlers: Handlers) { const b = document.createElement('button') b.type = 'button' b.dataset.action = id - if (chargeOf(id)) b.classList.add('charge') + // Charges and power-up abilities share the smaller second row. + if (chargeOf(id) || ACTIONS[id].ability) b.classList.add('charge') b.innerHTML = `${icon(ICONS[id])}<span class="label">${ACTIONS[id].label}</span><kbd>${KEYS[id]}</kbd>` if (chargeOf(id)) b.insertAdjacentHTML('beforeend', '<span class="count"></span>') b.addEventListener('click', () => handlers.pick(id)) @@ -70,6 +83,9 @@ export function createControls(handlers: Handlers) { b.querySelector('.icon')!.setAttribute('src', spriteUrl(autoMove ? 'flag_green_a' : ICONS.move)) b.classList.toggle('auto', autoMove) } + // Abilities only get a button once the player has them. + const ability = ACTIONS[id].ability + if (ability) b.hidden = !s.powers[ability] // Charges only get a button while the player holds some; a badge shows how many. const charge = chargeOf(id) if (charge) { @@ -88,12 +104,21 @@ export function createControls(handlers: Handlers) { // Turns taken, and the par to beat for the bonus. hud.turn.innerHTML = `<small aria-hidden="true">Turn</small>` + number(s.turn, 'Turn ') + `<small class="par">/${s.par}</small>` + // Bomb limit, blast size and fuse, then a small icon for each pass held and the Star's turns. + const passes = PASSES.filter(([p]) => s.powers[p]) + .map(([, kind, name]) => `<span class="sr-only">, ${name}</span>${icon(POWER_UP_ICONS[kind], 'icon small')}`) + .join('') + const star = s.star > 0 ? icon('star', 'icon small') + number(s.star, ', Star for ') : '' hud.stats.innerHTML = - icon('bomb') + number(s.stats.capacity, 'Bombs ') + icon('fireball') + number(s.stats.fire, ', fire ') + icon('bomb') + number(s.stats.capacity, 'Bombs ') + icon('fireball') + number(s.stats.fire, ', fire ') + + icon('torch_on_a') + number(s.stats.fuse, ', fuse ') + passes + star banner.hidden = s.status === 'playing' if (s.status === 'dead') { - bannerText.textContent = `Ouch! ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.` + const lost = LOST_ON_DEATH.filter(([p]) => s.powers[p]).map(([, , name]) => name) + if (s.star > 0) lost.push('Star') + bannerText.textContent = + `Ouch! ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.` + (lost.length ? ` Lost: ${lost.join(', ')}.` : '') bannerButton.textContent = 'Try again' bannerIcon.src = spriteUrl('character_green_hit') } else if (s.status === 'gameOver') { @@ -105,6 +130,10 @@ export function createControls(handlers: Handlers) { bannerText.textContent = `Level ${s.level} cleared in ${s.turn} turns.${bonus}` 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}.` + bannerButton.textContent = 'New run' + bannerIcon.src = spriteUrl('star') } }, /** True if the banner is showing; Enter presses its button. */ diff --git a/test/helpers.ts b/test/helpers.ts index 178debc..b2d2783 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,4 +1,4 @@ -import { noCharges, START_LIVES, START_STATS } from '../src/sim/state' +import { noCharges, noPowers, START_LIVES, START_STATS } from '../src/sim/state' import type { Cell, EnemyKind, GameState, Stats, Vec } from '../src/sim/types' const ENEMIES: Record<string, EnemyKind> = { B: 'blob', S: 'seeker', D: 'drifter' } @@ -20,7 +20,8 @@ export function board(picture: string, stats: Partial<Stats> = {}): GameState { seed: 1, level: 1, turn: 5, width, height, cells, player: { x: 0, y: 0 }, lives: START_LIVES, stats: { ...START_STATS, ...stats }, bombs: [], status: 'playing', enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 100, coins: 0, - charges: noCharges(), shield: false, frozen: false, decoy: null, burning: [], aiRng: 1, dropRng: 1, + charges: noCharges(), shield: false, frozen: false, decoy: null, burning: [], + powers: noPowers(), star: 0, powerUp: null, runTurns: 0, deaths: 0, 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/powerups.test.ts b/test/powerups.test.ts new file mode 100644 index 0000000..1598aea --- /dev/null +++ b/test/powerups.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest' +import { ACTIONS, isAvailable } from '../src/sim/actions' +import { generateLevel, LAST_LEVEL, skatesLevel } from '../src/sim/levelgen' +import { newRun } from '../src/sim/state' +import { COIN_BAG, EXIT_SPAWN, STAR_TURNS, step } from '../src/sim/turn' +import type { GameState, PowerUpKind } from '../src/sim/types' +import { board, v } from './helpers' + +describe('levelgen: the hidden power-up', () => { + for (let seed = 1; seed <= 100; seed++) { + it(`seed ${seed}: under a soft block that isn't the exit's`, () => { + const g = generateLevel(seed, 1 + (seed % 50)) + expect(g.cells[g.powerUp.pos.y * g.width + g.powerUp.pos.x]).toBe('block') + expect(g.powerUp.pos).not.toEqual(g.exit) + }) + } + + it('Skates come on level 1 or 2, and only there', () => { + for (let seed = 1; seed <= 50; seed++) { + const at = skatesLevel(seed) + expect([1, 2]).toContain(at) + expect(generateLevel(seed, at).powerUp.kind).toBe('skates') + for (let level = 1; level <= 12; level++) if (level !== at) expect(generateLevel(seed, level).powerUp.kind).not.toBe('skates') + } + }) +}) + +/** A board with the power-up `kind` lying uncovered at (2, 1). */ +function withPowerUp(kind: PowerUpKind, picture = ` + ###### + #P...# + ######`): GameState { + const s = board(picture) + s.powerUp = { pos: v(2, 1), kind } + return s +} + +describe('picking up power-ups', () => { + const take = (kind: PowerUpKind) => step(withPowerUp(kind), { id: 'move', target: v(2, 1) }) + + it('stats: Bomb+, Fire+, Fuse+', () => { + expect(take('bombPlus').state.stats.capacity).toBe(2) + expect(take('firePlus').state.stats.fire).toBe(2) + expect(take('fusePlus').state.stats.fuse).toBe(4) + }) + + it('Heart, Coin bag, Star', () => { + expect(take('heart').state.lives).toBe(4) + expect(take('coinBag').state.coins).toBe(COIN_BAG) + // Taken this turn, and this turn's end already counts one off. + expect(take('star').state.star).toBe(STAR_TURNS - 1) + }) + + it('abilities, and the event; the power-up is gone afterwards', () => { + const r = take('skates') + expect(r.state.powers.run).toBe(true) + expect(r.state.powerUp).toBeNull() + expect(r.events).toContainEqual({ type: 'powerUpTaken', powerUp: { pos: v(2, 1), kind: 'skates' } }) + expect(take('detonator').state.powers.detonate).toBe(true) + }) + + it('a power-up still under its block cannot be taken', () => { + const s = withPowerUp('heart', ` + ###### + #P+..# + ######`) + s.powers.wallPass = true + expect(step(s, { id: 'move', target: v(2, 1) }).state.lives).toBe(3) + }) + + it('burning its block reveals it', () => { + const s = withPowerUp('heart', ` + ###### + #P.1+# + ######`) + s.powerUp!.pos = v(4, 1) + const { state, events } = step(s, { id: 'wait' }) + expect(events).toContainEqual({ type: 'powerUpRevealed', powerUp: { pos: v(4, 1), kind: 'heart' } }) + expect(state.powerUp).not.toBeNull() + }) + + it('flame on it once uncovered destroys it and lets enemies out', () => { + const s = withPowerUp('heart', ` + ###### + #P..1# + ######`) + s.powerUp!.pos = v(3, 1) + const { state, events } = step(s, { id: 'wait' }) + expect(state.powerUp).toBeNull() + expect(events.map((e) => e.type)).toContain('powerUpBurned') + expect(state.enemies).toHaveLength(EXIT_SPAWN) + }) +}) + +describe('Run (Skates)', () => { + it('goes 2 tiles in a straight line, only over free tiles', () => { + const s = board(` + ####### + #P...3# + #.##### + #+##### + #######`) + expect(isAvailable(s, 'run')).toBe(false) + s.powers.run = true + expect(ACTIONS.run.validTargets(s)).toEqual([v(3, 1)]) + expect(step(s, { id: 'run', target: v(3, 1) }).state.player).toEqual(v(3, 1)) + }) + + it('collects what is on the tile it runs through', () => { + const s = board(` + ###### + #P$..# + ######`) + s.powers.run = true + expect(step(s, { id: 'run', target: v(3, 1) }).state.coins).toBe(1) + }) + + it('running into an enemy on the tile in between is death', () => { + const s = board(` + ###### + #PB..# + ######`) + s.powers.run = true + expect(ACTIONS.run.validTargets(s)).toEqual([v(3, 1)]) + expect(step(s, { id: 'run', target: v(3, 1) }).state.status).toBe('dead') + }) +}) + +describe('Detonate', () => { + it('sets one of your bombs off this turn', () => { + const s = board(` + ####### + #P..3+# + #######`) + s.powers.detonate = true + const { state, events } = step(s, { id: 'detonate', target: v(4, 1) }) + expect(events).toContainEqual({ type: 'detonated', pos: v(4, 1) }) + expect(state.bombs).toEqual([]) + expect(state.cells[7 + 5]).toBe('floor') + }) +}) + +describe('passes', () => { + it('Wall Pass walks through soft blocks', () => { + const s = board(` + ##### + #P+.# + #####`) + expect(ACTIONS.move.validTargets(s)).toEqual([]) + s.powers.wallPass = true + expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)]) + }) + + it('Bomb Pass walks over bombs', () => { + const s = board(` + ##### + #P3.# + #####`) + s.powers.bombPass = true + expect(ACTIONS.move.validTargets(s)).toEqual([v(2, 1)]) + }) + + it('Flame Pass: flame does not hurt; enemies still do', () => { + const s = board(` + ##### + #P1.# + #####`) + s.powers.flamePass = true + expect(step(s, { id: 'wait' }).state.status).toBe('playing') + const e = board(` + ##### + #PB.# + #####`) + e.powers.flamePass = true + expect(step(e, { id: 'move', target: v(2, 1) }).state.status).toBe('dead') + }) + + it('Star: nothing hurts while it lasts', () => { + const s = board(` + ##### + #PB1# + #####`) + s.star = 3 + expect(step(s, { id: 'move', target: v(2, 1) }).state.status).toBe('playing') + }) + + it('a death loses the passes, Detonator and Star, but keeps Skates and stats', () => { + const s = { ...board(` + ##### + #P1.# + #####`, { capacity: 2 }), star: 0 } + s.powers = { run: true, detonate: true, wallPass: true, bombPass: true, flamePass: false } + const dead = step(s, { id: 'wait' }).state + expect(dead.deaths).toBe(1) + const again = step(dead, { id: 'continue' }).state + expect(again.powers).toEqual({ run: true, detonate: false, wallPass: false, bombPass: false, flamePass: false }) + expect(again.stats.capacity).toBe(2) + }) + + it('clearing a level keeps everything', () => { + const s = { ...board(` + #### + #PE# + ####`), star: 4 } + s.powers.wallPass = true + const next = step(step(s, { id: 'move', target: v(2, 1) }).state, { id: 'continue' }).state + expect(next.powers.wallPass).toBe(true) + expect(next.star).toBe(4) + }) +}) + +describe('the ending', () => { + it(`clearing level ${LAST_LEVEL} wins the run`, () => { + const s = { ...board(` + #### + #PE# + ####`), level: LAST_LEVEL, runTurns: 1234, deaths: 2 } + const { state, events } = step(s, { id: 'move', target: v(2, 1) }) + expect(state.status).toBe('won') + expect(events.at(-1)).toEqual({ type: 'runWon', turns: 1235, deaths: 2 }) + expect(() => step(state, { id: 'continue' })).toThrow() + }) + + it('counts turns across the whole run', () => { + let s = newRun(3) + s = step(s, { id: 'wait' }).state + s = step(s, { id: 'wait' }).state + expect(s.runTurns).toBe(2) + }) +}) diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index 56f2232..60a99ce 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -63,6 +63,15 @@ const SHOTS = [ }, // A Pierce bomb (spikes badge) placed to the right, then a Decoy (pale copy of the player) below. { name: 'pierce-decoy', query: 'charges=2', act: press('KeyI', 'KeyD', 'KeyY', 'KeyS') }, + // Seed 55 hides Skates (a green gem) under the crate two tiles below the start: bomb it open + // and step back once the flame has died down... + { name: 'power-up', query: 'seed=55', act: press('KeyB', 'KeyS', 'KeyD', 'KeyD', 'Space', 'KeyA') }, + // ...then walk back and take it: Run appears, with its 2-tile targets picked. + { + name: 'run', + query: 'seed=55', + act: press('KeyB', 'KeyS', 'KeyD', 'KeyD', 'Space', 'KeyA', 'KeyA', 'KeyS', 'KeyS', 'KeyR'), + }, // Holding action charges: their buttons appear; Throw picked shows where a bomb can land. { name: 'charges', query: 'charges=2', act: async (page) => { await page.click('[data-action=throw]') } }, ] @@ -73,7 +82,7 @@ const browser = await chromium.launch() try { for (const { name, viewport = PHONE, query = '', act } of SHOTS) { const page = await browser.newPage({ viewport, deviceScaleFactor: 2, hasTouch: viewport === PHONE }) - await page.goto(`http://localhost:4179/?seed=1&${query}`) + await page.goto(`http://localhost:4179/?${query.includes('seed=') ? '' : 'seed=1&'}${query}`) await page.waitForSelector('canvas') await page.waitForTimeout(300) await act(page)