explodorama / commit
Exit door, par, enemies, drops and the first action charges (M4, M5)
PLAN.md | 38 +++-- README.md | 74 +++++++- assets/kenney/chain.png | Bin 0 -> 1457 bytes assets/kenney/sign_right.png | Bin 0 -> 1316 bytes assets/kenney/spring_out.png | Bin 0 -> 2361 bytes assets/kenney/switch_blue.png | Bin 0 -> 1490 bytes decisions.md | 18 ++ docs/screenshots/bomb-picked.png | Bin 173420 -> 238948 bytes docs/screenshots/charges.png | Bin 0 -> 261017 bytes docs/screenshots/countdown.png | Bin 178477 -> 244041 bytes docs/screenshots/death.png | Bin 165699 -> 197380 bytes docs/screenshots/desktop.png | Bin 569343 -> 586370 bytes docs/screenshots/enemies.png | Bin 0 -> 252615 bytes docs/screenshots/scrolled.png | Bin 151868 -> 253517 bytes docs/screenshots/start.png | Bin 172573 -> 237858 bytes index.html | 2 + src/main.ts | 26 ++- src/render/board.ts | 56 +++++- src/render/sprites.ts | 45 +++++ src/sim/actions.ts | 94 ++++++++++- src/sim/enemies.ts | 116 +++++++++++++ src/sim/levelgen.ts | 48 +++++- src/sim/record.ts | 10 +- src/sim/state.ts | 55 +++++- src/sim/turn.ts | 106 ++++++++++-- src/sim/types.ts | 62 ++++++- src/style.css | 68 +++++++- src/ui/controls.ts | 42 +++-- test/danger.test.ts | 11 +- test/enemies.test.ts | 355 +++++++++++++++++++++++++++++++++++++++ test/helpers.ts | 19 ++- tools/screenshots.mjs | 8 +- 32 files changed, 1151 insertions(+), 102 deletions(-) diff --git a/PLAN.md b/PLAN.md index 0c583eb..4c7ade7 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–M2 done, M3 mostly done** (see §18). This document is the design and the hand-off +Status: **M0–M4 done, M5 well 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`. @@ -107,13 +107,13 @@ The button shows a count, e.g. "Kick ×2". Using one spends one charge and the t | **Kick** | Push an adjacent bomb; it slides until the next tile is not free. It keeps its timer. The escape hatch for boxing yourself in. | | **Throw** | Place a bomb **exactly 2 tiles away** in a straight line, over a wall/soft block/bomb. Target must be free. | | **Fuse +1 / Fuse −1** | Change any visible bomb's timer by 1 (minimum 1). One action; the direction is chosen at target time. | -| **Shield** | Absorb the next hit (flame or enemy). Shown as a ring around the player. Arming it costs the turn. | +| **Shield** | Absorb the next hit (flame or enemy). Shown as a ring around the player. Arming it costs the turn. *Built:* it also absorbs any further hit in the same turn it breaks (e.g. an enemy and then a flame), so one shield never fails halfway through a turn. | | **Diagonal step** | Move 1 tile diagonally. Both orthogonal neighbours may be blocked — it's a squeeze. | | **Teleport** (rare) | Jump to any free tile within Manhattan distance 3. | | **Pierce bomb** | Place a bomb whose flame passes **through** soft blocks (destroying all in range). From later Bomberman games. | | **Power bomb** | Place a bomb with maximum fire (whole row/column up to hard walls). | | **Line bomb** | Place all remaining bomb capacity in a straight line from the player, in one action. From *Super Bomberman*. | -| **Freeze** | All enemies skip their next move (intents greyed out). | +| **Freeze** | All enemies skip their next move. *Built:* that is the move their arrows show when Freeze is used, i.e. this same turn's phase 2, so there is never a greyed-out state to show. | | **Decoy** | Place a decoy on an adjacent tile; chasing enemies target it instead of the player for 3 turns. Flame destroys it. | All charge actions can also appear in the shop (§8). @@ -289,6 +289,11 @@ contact. Intent shown every turn. Adapted from the NES eight: - Until Reapers exist, "destroyed pickup/exit" spawns a group of the level's tougher types. - Base drop table: ~30–40% action charge, separate coin roll, rare skull from level 2. Tune. + *Built (2026-10-20):* per type — Blob 20% coins / 15% charge, Seeker 50% / 25%, Drifter + 20% / 50%; a coin drop is worth 1 (60%), 2 (30%) or 5 (10%). Counts: 3 enemies on level 1, + +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. - **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`. @@ -305,7 +310,8 @@ contact. Intent shown every turn. Adapted from the NES eight: - **Camera:** all 13 rows always fit vertically (portrait phone is the primary target), about 7 columns visible on a 390 × 844 phone (measured), since 13 rows must fill the height. Follows the player **horizontally**, eased between turns, clamped to the level edges. Landscape/desktop show more columns. -- **Par:** each level has a turn budget; finishing under it pays a coin bonus. +- **Par:** each level has a turn budget; finishing under it pays a coin bonus. *Built:* par = + 50 + 10 × enemies, bonus 5 coins; the HUD shows `turn / par`. (Turn **limit** with Reapers: later milestone.) - **50 levels**, difficulty rising through enemy count, types, speed and soft-block density. - **Game over** at 0 lives → run summary (level reached, coins banked, kills, Keep/Fresh choices, @@ -385,15 +391,17 @@ Sprite assignments (✅ = drawn now; the rest are copied and waiting for their f | ✅ soft block | `block_planks` (wooden crate) | | ✅ bomb | `bomb`; flashes to `bomb_active` on its last turn; timer drawn in `hud_character_0`–`9` | | ✅ flame | `fireball` (spinning, fading) over an orange tile | -| ✅ HUD | `hud_heart` / `hud_heart_empty` (lives), `flag_green_a` (level), `bomb`, `fireball`, digits | +| ✅ HUD | `hud_heart` / `hud_heart_empty` (lives), `flag_green_a` (level), `slime_normal_rest` (enemies left), `hud_coin` (coins), `bomb`, `fireball`, digits | | ✅ action buttons | Move `character_green_walk_a`, Bomb `bomb`, Wait `character_green_duck` | -| ✅ banners | `character_green_hit` (lost a life), `hud_heart_empty` (game over); favicon `bomb` | -| exit (closed / open) | `door_closed` + `door_closed_top`, `door_open` + `door_open_top`; `lock_yellow` | -| coins | `coin_gold`, `coin_silver`, `coin_bronze`; `hud_coin` in the HUD | +| ✅ banners | `character_green_hit` (lost a life), `hud_heart_empty` (game over), `flag_green_a` (level cleared); favicon `bomb` | +| ✅ exit (locked / open) | `door_closed` + `door_closed_top`, `door_open` + `door_open_top`, squeezed into one tile. (`lock_yellow` spare.) | +| ✅ coins | `coin_bronze` (1), `coin_silver` (2), `coin_gold` (5), bobbing; `hud_coin` in the HUD | +| ✅ action charges | Kick `spring_out`, Throw `sign_right`, Shield `switch_blue`, Freeze `chain` (drops and buttons) | +| ✅ intent arrows | drawn red lines with a head, not sprites | | pickups (power-ups) | gems: `gem_red` Fire +1, `gem_blue` Bomb +1, `gem_yellow` Fuse +1, `gem_green` Skates (suggested; final mapping when M6 is built); `heart` Heart; `star` Star | -| Blob | `slime_normal_rest`, `_walk_a`, `_walk_b`, `_flat` (squashed when killed) | -| Seeker | `fly_rest`, `fly_a`, `fly_b` | -| Drifter | `saw_rest`, `saw_a`, `saw_b` | +| ✅ 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 | | Sprinter | `mouse_rest`, `mouse_walk_a`, `mouse_walk_b` | | Ghost (passes soft blocks) | `slime_block_rest`, `_walk_a`, `_walk_b`, `_jump` | | Wisp | `slime_fire_rest`, `_walk_a`, `_walk_b`, `_flat` | @@ -541,8 +549,12 @@ tools/ icons.mjs (PWA icons), screenshots.mjs (Playwright) 4. **M3 – Mobile UI:** ✅ action bar, two-tap targeting, highlights, danger overlay, commit preview (press-and-hold / hover), keyboard controls; Kenney sprites (2026-10-20). ⏳ No animation of moves yet (instant), only a flame flash. -5. **M4 – World:** ✅ levelgen (31 × 13), side-scrolling camera. ⏳ Exit door, par. -6. **M5 – Enemies:** Blob, Seeker, Drifter first; intents, drops, charge inventory, charge actions. +5. **M4 – World:** ✅ levelgen (31 × 13), side-scrolling camera, exit door under a soft block + (opens when all enemies are dead; flaming it spawns 3 enemies), par + coin bonus (2026-10-20). +6. **M5 – Enemies:** ✅ Blob, Seeker, Drifter with intent arrows, both contact checks, coin and + charge drops, coins, charge inventory (buttons with counts), and the first 4 charge actions: + Kick, Throw, Shield, Freeze (2026-10-20). ⏳ The other 7 charge actions (Fuse ±, Diagonal, + Teleport, Pierce, Power, Line, Decoy). 7. **M6 – Run loop:** NES pickups, coins, shop after each level, Keep/Fresh death choice, curses from level 2, 50-level progression and ending, mid-run save. 8. **M7 – Look & feel:** animations, sounds (Kenney pack, §13), PWA icons, remaining diff --git a/README.md b/README.md index ca216cb..a5f0b93 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,19 @@ 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 and lives work. -Enemies, pickups, the shop and the 50-level run are planned — see [PLAN.md](PLAN.md). +Status: **early development.** The board, moving, bombs, chain reactions, lives, the first three +enemy types, coins, action charges and the exit to the next level work. Power-ups, the shop, +more enemies and the 50-level ending are planned — see [PLAN.md](PLAN.md).  ## How to play -You are the green astronaut. Each turn you pick **one** action; then the world takes its turn. +You are the green astronaut. **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 +where their arrows pointed, bombs count down and explode, and enemies show their next step. | Action | Phone | Keyboard | What it does | |---|---|---|---| @@ -19,11 +24,22 @@ You are the green astronaut. Each turn you pick **one** action; then the world t | **Bomb** | Tap **Bomb**, then a highlighted tile | **B**, then a direction | Place a bomb on an empty tile next to you. You can have 1 bomb on the board at a time. | | **Wait** | Tap **Wait** | Space | Do nothing; the world takes its turn. | +**Action charges** are one-use actions that enemies sometimes drop. Their buttons appear in a +second row, with a badge showing how many you hold; using one spends one and takes your turn. + +| Charge | Icon | Phone | Keyboard | What it does | +|---|---|---|---|---| +| **Kick** | spring | Tap **Kick**, then a bomb next to you | **K**, then a direction | The bomb slides away from you until the next tile is blocked (a wall, crate, bomb or enemy). It keeps its number. | +| **Throw** | arrow sign | Tap **Throw**, then a highlighted tile | **T**, then a direction | Place a bomb exactly 2 tiles away in a straight line, over anything in between. Uses your bomb limit like a normal bomb. | +| **Shield** | blue dome | Tap **Shield** | **H** | A ring appears round you. The next hit (flame or enemy) is absorbed, and so is anything else that hits you in that same turn. | +| **Freeze** | chain | Tap **Freeze** | **F** | Every enemy skips the step its arrow shows this turn. | + - Picking an action highlights the tiles you can choose, in blue. Tapping the action again, pressing **Esc**, or tapping anywhere else cancels it. - **Preview before you commit:** on a phone, press and hold a highlighted tile to see the result (for a bomb, its blast in orange); slide to another tile to change your mind; let go - to commit. With a mouse, hovering shows the same preview. + to commit. With a mouse, hovering shows the same preview. For a kick, the preview shows + where the bomb will stop. ### Bombs @@ -32,17 +48,57 @@ You are the green astronaut. Each turn you pick **one** action; then the world t - The blast is a cross reaching **1 tile** in each direction. It stops at walls; it destroys the first soft block (wooden crate) in each direction and stops there. - A bomb caught in a blast **explodes at once**, whatever its number (a chain reaction). -- Bombs block movement. +- Bombs block movement, for you and for enemies. +- Bombs can't be placed on an enemy. ### Danger - **Strong red** tiles will burn at the end of this turn if you wait. - **Light red** tiles will burn at the end of the next turn. +### Enemies + +<img src="docs/screenshots/enemies.png" alt="Level 13: a Drifter saw, a Blob and a Seeker fly, the moving ones with red arrows" width="260" align="right"> + +Every enemy shows a **red arrow** toward the tile it will step onto next turn. No arrow (and +closed eyes): it has nowhere to go and stays put. If you put a bomb on the tile an arrow +points at, that enemy stays put this turn. Touching an enemy costs a life — walking into it, it +walking into you, or the two of you swapping places. Flame kills enemies. + +| Enemy | Looks like | Appears from | Moves | +|---|---|---|---| +| **Blob** | purple slime | level 1 | Wanders; usually keeps going the way it was heading. | +| **Seeker** | fly | level 7 | Half the time takes the shortest way toward you; otherwise wanders like a Blob. | +| **Drifter** | spinning saw | level 13 | Goes straight until blocked, then turns. | + +Level 1 has 3 enemies, with one more every 3 levels (at most 10). Enemies never start within +4 tiles of you. + +### 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 +open, so pick them up soon. Coins will buy upgrades in the shop (not built yet). + +### The exit + +- The exit door is hidden under one of the crates. Burning that crate reveals it. +- The door stays **locked** while any enemy is alive (top-bar enemy count). When the last one + dies, it opens; step onto it to finish the level. +- **Don't blast the uncovered door:** flame on it lets out 3 more enemies (of the level's + toughest type). Those drop nothing. +- **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. + ### Lives -You have **3 lives**. Caught in a blast, you lose one and the level starts again with the same -layout. With none left, the run is over and the seed is shown, so the same board can be replayed. +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. + +### The top bar + +Lives (hearts), level (flag), enemies left, coins, turn / par, and your bomb limit and blast size. ### The board @@ -60,7 +116,9 @@ npm run build # type-check and build into dist/ npm run screenshots # build, then photograph the game into docs/screenshots/ (Playwright) ``` -URL parameters: `?seed=123` plays a fixed board; `?level=4` starts at a later level. +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. Deployed to GitLab Pages by `.gitlab-ci.yml` on every push to the default branch. diff --git a/assets/kenney/chain.png b/assets/kenney/chain.png new file mode 100644 index 0000000..b5763bf Binary files /dev/null and b/assets/kenney/chain.png differ diff --git a/assets/kenney/sign_right.png b/assets/kenney/sign_right.png new file mode 100644 index 0000000..dbce9af Binary files /dev/null and b/assets/kenney/sign_right.png differ diff --git a/assets/kenney/spring_out.png b/assets/kenney/spring_out.png new file mode 100644 index 0000000..8b98d0e Binary files /dev/null and b/assets/kenney/spring_out.png differ diff --git a/assets/kenney/switch_blue.png b/assets/kenney/switch_blue.png new file mode 100644 index 0000000..3354688 Binary files /dev/null and b/assets/kenney/switch_blue.png differ diff --git a/decisions.md b/decisions.md index f26bba2..97329a6 100644 --- a/decisions.md +++ b/decisions.md @@ -74,3 +74,21 @@ the README credits Kenney anyway. - **Not pixelated.** `setTilesPixelated(false)`: the sprites are smooth art drawn at about 100–110 device pixels from 128, which looks jagged without smoothing. - **Pale floor, not grass.** The player is mint green; a green floor hid them. + +### 2026-10-20 — Enemies, exit and charges: rules settled while building M4/M5 + +- **Bombs can't be placed on an enemy's tile** (Bomb and Throw). Otherwise a bomb could sit + under an enemy, which the turn rules don't cover. +- **Enemies don't block each other** and may share a tile. Simpler, and the NES did the same. +- **Shield absorbs every hit for the rest of the turn it breaks in**, not just the first one, so + an enemy step followed by a flame in the same turn doesn't kill through a shield. +- **Freeze cancels the move already announced**, in the same turn, rather than the one after. + The arrows the player sees are what Freeze stops, which is easier to read. +- **Kicked bombs stop at enemies** as well as at walls, crates and bombs. +- **Exit-spawned enemies drop nothing** (3 of the level's toughest type), so flaming the exit is + only a punishment, never a coin farm. +- **Par = 50 + 10 per enemy, bonus 5 coins.** A first guess, to tune by playtesting. +- **`?charges=N` is part of the run record** (`startCharges`), since it changes the starting + state; replays stay exact. `SIM_VERSION` is now 2. +- **Death banner says "Ouch!"**, not "Boom.", since enemies can kill too now. + diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png index d5507da..b4b2f2d 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 new file mode 100644 index 0000000..0d2d81e Binary files /dev/null and b/docs/screenshots/charges.png differ diff --git a/docs/screenshots/countdown.png b/docs/screenshots/countdown.png index 073e704..f0abe4b 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 54ee735..f009bae 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 947a818..ff4b38d 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 new file mode 100644 index 0000000..413e83f Binary files /dev/null and b/docs/screenshots/enemies.png differ diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png index 999adfd..119129c 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 c7b6252..0a69656 100644 Binary files a/docs/screenshots/start.png and b/docs/screenshots/start.png differ diff --git a/index.html b/index.html index a71dcf5..0ed27fa 100644 --- a/index.html +++ b/index.html @@ -15,6 +15,8 @@ <header id="hud" aria-live="polite"> <span id="hud-lives"></span> <span id="hud-level"></span> + <span id="hud-enemies"></span> + <span id="hud-coins"></span> <span id="hud-turn"></span> <span id="hud-stats"></span> </header> diff --git a/src/main.ts b/src/main.ts index 80fa620..8c590e5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,7 +16,7 @@ import { TEXTURES } from './render/sprites' import { isAvailable, isValidTarget, ACTIONS } from './sim/actions' import { predictDanger, type Danger } from './sim/danger' import { newRecord, type RunRecord } from './sim/record' -import { add, inBounds, newRun } from './sim/state' +import { inBounds, newRun } from './sim/state' import { step } from './sim/turn' import { DIRS, type Action, type ActionId, type Dir, type GameState, type Vec } from './sim/types' import { createControls } from './ui/controls' @@ -25,6 +25,8 @@ import { createControls } from './ui/controls' // seed — chosen here, never inside the sim. const params = new URLSearchParams(location.search) const startLevel = Number(params.get('level')) || 1 +// `?charges=2` starts with 2 of every action charge, to try them without hunting for drops. +const startCharges = Number(params.get('charges')) || 0 let record: RunRecord let state: GameState @@ -36,8 +38,8 @@ let facingLeft = false let snapCamera = true function startRun(seed: number): void { - record = newRecord(seed, startLevel) - state = newRun(seed, startLevel) + record = newRecord(seed, startLevel, startCharges) + state = newRun(seed, startLevel, startCharges) afterChange() } @@ -51,7 +53,7 @@ function act(action: Action): void { record.actions.push(action) for (const e of result.events) { if (e.type === 'exploded') flames.push({ tiles: e.tiles, born: time }) - if (e.type === 'levelRestarted') snapCamera = true + if (e.type === 'levelRestarted' || e.type === 'levelStarted') snapCamera = true if (e.type === 'moved' && e.to.x !== e.from.x) facingLeft = e.to.x < e.from.x } selected = null @@ -84,18 +86,22 @@ function pick(id: ActionId): void { } } -/** A direction key: places a bomb that way if Bomb is picked, otherwise moves. */ +/** + * 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. + */ function direction(d: Dir): void { if (state.status !== 'playing') return - const p = add(state.player, DIRS[d]) - const id: ActionId = selected === 'bomb' ? 'bomb' : 'move' + const id: ActionId = selected && ACTIONS[selected].needsTarget ? selected : 'move' + const reach = id === 'throw' ? 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 }) } const controls = createControls({ pick, banner() { - if (state.status === 'dead') act({ id: 'continue' }) + if (state.status === 'dead' || state.status === 'cleared') act({ id: 'continue' }) else if (state.status === 'gameOver') startRun(randomSeed()) }, }) @@ -107,12 +113,14 @@ const KEY_DIRS: Record<string, Dir> = { KeyD: 'right', ArrowRight: 'right', } +const ACTION_KEYS: Record<string, ActionId> = { KeyB: 'bomb', KeyK: 'kick', KeyT: 'throw', KeyH: 'shield', KeyF: 'freeze' } + window.addEventListener('keydown', (e) => { if (e.repeat) return const d = KEY_DIRS[e.code] if (d) direction(d) - else if (e.code === 'KeyB') pick('bomb') else if (e.code === 'Space') pick('wait') + else if (ACTION_KEYS[e.code]) pick(ACTION_KEYS[e.code]!) else if (e.code === 'Escape') { selected = null afterChange() diff --git a/src/render/board.ts b/src/render/board.ts index 5277b15..b7f2d9e 100644 --- a/src/render/board.ts +++ b/src/render/board.ts @@ -1,10 +1,10 @@ -import { drawRect, drawTile, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine' -import { ACTIONS } from '../sim/actions' +import { drawCircle, drawLine, drawPoly, drawRect, drawTile, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine' +import { ACTIONS, kickDestination } from '../sim/actions' import type { Danger } from '../sim/danger' import { blastTiles } from '../sim/explosions' -import { eq, idx } from '../sim/state' -import type { ActionId, GameState, Vec } from '../sim/types' -import { digitSprite, sprite, type BoardSprite } from './sprites' +import { cellAt, eq, exitOpen, idx } from '../sim/state' +import type { ActionId, Enemy, GameState, Vec } from '../sim/types' +import { CHARGE_ICONS, coinSprite, digitSprite, ENEMY_SPRITES, sprite, type BoardSprite } from './sprites' /** * The one place where sim tiles meet LittleJS world space. The sim's y grows downward @@ -49,6 +49,9 @@ const C = { flame: rgb(1, 0.6, 0.1), white: rgb(1, 1, 1), ghost: rgb(1, 1, 1, 0.45), + intent: rgb(0.85, 0.2, 0.2, 0.85), + shield: rgb(0.25, 0.62, 0.87, 0.9), + clear: rgb(0, 0, 0, 0), } export const BACKGROUND: Color = C.sky @@ -75,6 +78,7 @@ export function drawBoard(view: View): void { } else if (cell === 'block') drawTile(pos, tileSize, sprite('block_planks')) } } + if (cellAt(s, s.exit) === 'floor') drawExit(toWorld(s, s.exit), exitOpen(s)) 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) @@ -104,11 +108,47 @@ export function drawBoard(view: View): void { const hot = b.timer <= 1 && Math.sin(time * 14) > 0 drawBomb(toWorld(s, b.pos), b.timer, hot ? 'bomb_active' : 'bomb', C.white) } - if (hovered && selected === 'bomb') drawBomb(toWorld(s, hovered), s.stats.fuse, 'bomb', C.ghost) + if (hovered && selected === 'kick') { + const bomb = s.bombs.find((b) => eq(b.pos, hovered)) + if (bomb) drawBomb(toWorld(s, kickDestination(s, hovered)), bomb.timer, 'bomb', C.ghost) + } + if (hovered && (selected === 'bomb' || selected === 'throw')) drawBomb(toWorld(s, hovered), s.stats.fuse, 'bomb', C.ghost) + + // Drops bob gently, so they read as things to pick up rather than part of the floor. + for (const [i, d] of s.drops.entries()) { + const pos = toWorld(s, d.pos).add(vec2(0, 0.05 * Math.sin(time * 3 + i))) + 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) + if (s.shield) drawCircle(toWorld(s, s.player), 1.08, C.clear, 0.07, C.shield) if (hovered && selected === 'move') drawTile(toWorld(s, hovered), tileSize, sprite('character_green_idle'), C.ghost, 0, view.facingLeft) - const alive = s.status === 'playing' - drawTile(toWorld(s, s.player), tileSize, sprite(alive ? 'character_green_idle' : 'character_green_hit'), C.white, 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) +} + +/** The pack's door is two tiles tall; it is drawn narrower so both halves fit about one tile. */ +function drawExit(pos: Vector2, open: boolean): void { + const half = vec2(0.56) + drawTile(pos.add(vec2(0, -0.22)), half, sprite(open ? 'door_open' : 'door_closed')) + drawTile(pos.add(vec2(0, 0.34)), half, sprite(open ? 'door_open_top' : 'door_closed_top')) +} + +/** An enemy, and its intent: an arrow toward the tile it will step onto next turn. */ +function drawEnemy(s: GameState, e: Enemy): void { + const pos = toWorld(s, e.pos) + const art = ENEMY_SPRITES[e.kind] + const frame = !e.intent ? art.rest : Math.floor(time * 2.5 + e.id) % 2 ? art.a : art.b + const angle = art.spins && e.intent ? time * 3 : 0 + const mirror = art.facesLeft && e.dir === 'right' + drawTile(pos, vec2(0.9), sprite(frame), C.white, angle, mirror) + if (!e.intent) return + const d = vec2(e.intent.x - e.pos.x, e.pos.y - e.intent.y) // world y is up + const tip = pos.add(d.scale(0.82)) + drawLine(pos.add(d.scale(0.5)), tip, 0.08, C.intent) + const side = vec2(-d.y, d.x).scale(0.13) + drawPoly([tip.add(d.scale(0.12)), tip.add(side), tip.subtract(side)], C.intent) } /** The bomb sprite with its timer on the body, which sits a little below and right of the tile's centre. */ diff --git a/src/render/sprites.ts b/src/render/sprites.ts index 26e0571..d0a97ac 100644 --- a/src/render/sprites.ts +++ b/src/render/sprites.ts @@ -1,4 +1,5 @@ import { tile, vec2, type TileInfo } from 'littlejsengine' +import type { ChargeId, EnemyKind } from '../sim/types' /** * Art from Kenney's New Platformer Pack 1.1 (CC0), copied into `assets/kenney/` under the pack's @@ -36,6 +37,26 @@ const BOARD = [ 'hud_character_7', 'hud_character_8', 'hud_character_9', + 'door_closed', + 'door_closed_top', + 'door_open', + 'door_open_top', + 'coin_bronze', + 'coin_silver', + 'coin_gold', + 'spring_out', + 'sign_right', + 'switch_blue', + 'chain', + 'slime_normal_rest', + 'slime_normal_walk_a', + 'slime_normal_walk_b', + 'fly_rest', + 'fly_a', + 'fly_b', + 'saw_rest', + 'saw_a', + 'saw_b', ] as const export type BoardSprite = (typeof BOARD)[number] @@ -55,3 +76,27 @@ export function sprite(name: BoardSprite): TileInfo { export function digitSprite(d: number): TileInfo { return sprite(`hud_character_${d}` as BoardSprite) } + +/** 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', + throw: 'sign_right', + shield: 'switch_blue', + freeze: 'chain', +} + +/** + * Each enemy type's frames: `rest` (eyes shut) when it has nowhere to go, `a`/`b` alternating + * otherwise. `facesLeft`: the art looks left, so it is mirrored when the enemy heads right. + * `spins`: turned as it moves instead (the saw). + */ +export const ENEMY_SPRITES: Record<EnemyKind, { rest: BoardSprite; a: BoardSprite; b: BoardSprite; facesLeft: boolean; spins: boolean }> = { + blob: { rest: 'slime_normal_rest', a: 'slime_normal_walk_a', b: 'slime_normal_walk_b', facesLeft: true, spins: false }, + seeker: { rest: 'fly_rest', a: 'fly_a', b: 'fly_b', facesLeft: true, spins: false }, + drifter: { rest: 'saw_rest', a: 'saw_a', b: 'saw_b', facesLeft: false, spins: true }, +} + +/** Coin drops look like their value: bronze 1, silver 2, gold 5 or more. */ +export function coinSprite(amount: number): BoardSprite { + return amount >= 5 ? 'coin_gold' : amount >= 2 ? 'coin_silver' : 'coin_bronze' +} diff --git a/src/sim/actions.ts b/src/sim/actions.ts index 32d96c4..015ab0b 100644 --- a/src/sim/actions.ts +++ b/src/sim/actions.ts @@ -1,5 +1,6 @@ -import { add, eq, isFree } from './state' -import { DIRS, DIR_LIST, type ActionId, type GameEvent, type GameState, type Vec } from './types' +import { enemyAt } from './enemies' +import { add, bombAt, eq, isFree } from './state' +import { CHARGE_IDS, DIRS, DIR_LIST, type ActionId, type ChargeId, type GameEvent, type GameState, type Vec } from './types' /** * The board actions (PLAN.md §3). Adding one = an entry here, its tests, and a button. @@ -10,6 +11,8 @@ export interface ActionDef { label: string /** False for actions like Wait that happen as soon as they are picked. */ needsTarget: boolean + /** A consumable action charge (PLAN.md §3.3): only offered while the player holds one, and spends one. */ + charge?: boolean validTargets(s: GameState): Vec[] apply(s: GameState, target: Vec | undefined, events: GameEvent[]): void } @@ -18,6 +21,24 @@ function neighbours(p: Vec): Vec[] { return DIR_LIST.map((d) => add(p, DIRS[d])) } +/** Where a bomb can go: free, and no enemy standing there. */ +function canHoldBomb(s: GameState, p: Vec): boolean { + return isFree(s, p) && !enemyAt(s, p) +} + +function placeBomb(s: GameState, pos: Vec, events: GameEvent[]): void { + s.bombs.push({ pos: { ...pos }, timer: s.stats.fuse, fire: s.stats.fire, placedTurn: s.turn }) + events.push({ type: 'bombPlaced', pos: { ...pos }, timer: s.stats.fuse }) +} + +/** Where a bomb kicked by the player slides to: on, away from them, until the next tile can't hold it. */ +export function kickDestination(s: GameState, from: Vec): Vec { + const d = { x: from.x - s.player.x, y: from.y - s.player.y } + let p = from + while (canHoldBomb(s, add(p, d))) p = add(p, d) + return p +} + export const ACTIONS: Record<ActionId, ActionDef> = { move: { id: 'move', @@ -34,12 +55,8 @@ export const ACTIONS: Record<ActionId, ActionDef> = { id: 'bomb', label: 'Bomb', needsTarget: true, - validTargets: (s) => (s.bombs.length >= s.stats.capacity ? [] : neighbours(s.player).filter((p) => isFree(s, p))), - apply(s, target, events) { - const pos = { ...target! } - s.bombs.push({ pos, timer: s.stats.fuse, fire: s.stats.fire, placedTurn: s.turn }) - events.push({ type: 'bombPlaced', pos, timer: s.stats.fuse }) - }, + validTargets: (s) => (s.bombs.length >= s.stats.capacity ? [] : neighbours(s.player).filter((p) => canHoldBomb(s, p))), + apply: (s, target, events) => placeBomb(s, target!, events), }, wait: { id: 'wait', @@ -50,9 +67,63 @@ export const ACTIONS: Record<ActionId, ActionDef> = { events.push({ type: 'waited' }) }, }, + kick: { + id: 'kick', + label: 'Kick', + needsTarget: true, + charge: true, + // An adjacent bomb with room to slide at least one tile away from the player. + validTargets: (s) => + DIR_LIST.map((d) => DIRS[d]).flatMap((d) => { + const p = add(s.player, d) + return bombAt(s, p) && canHoldBomb(s, add(p, d)) ? [p] : [] + }), + apply(s, target, events) { + const bomb = bombAt(s, target!)! + const from = bomb.pos + bomb.pos = kickDestination(s, from) + events.push({ type: 'bombKicked', from, to: bomb.pos }) + }, + }, + throw: { + id: 'throw', + label: 'Throw', + needsTarget: true, + charge: true, + // Exactly 2 tiles away in a straight line, over anything in between. + validTargets: (s) => + s.bombs.length >= s.stats.capacity + ? [] + : DIR_LIST.map((d) => add(s.player, { x: 2 * DIRS[d].x, y: 2 * DIRS[d].y })).filter((p) => canHoldBomb(s, p)), + apply: (s, target, events) => placeBomb(s, target!, events), + }, + shield: { + id: 'shield', + label: 'Shield', + needsTarget: false, + charge: true, + validTargets: () => [], + apply(s) { + s.shield = true + }, + }, + freeze: { + id: 'freeze', + label: 'Freeze', + needsTarget: false, + charge: true, + validTargets: () => [], + apply(s) { + s.frozen = true + }, + }, } -export const ACTION_ORDER: readonly ActionId[] = ['move', 'bomb', 'wait'] +export const ACTION_ORDER: readonly ActionId[] = ['move', 'bomb', 'wait', ...CHARGE_IDS] + +export function isCharge(id: ActionId): id is ChargeId { + return ACTIONS[id].charge === true +} export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean { return ACTIONS[id].validTargets(s).some((p) => eq(p, target)) @@ -61,6 +132,11 @@ export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean /** Whether the action can be picked right now. */ export function isAvailable(s: GameState, id: ActionId): boolean { if (s.status !== 'playing') return false + if (isCharge(id)) { + if (s.charges[id] <= 0) return false + if (id === 'shield' && s.shield) return false + if (id === 'freeze' && s.enemies.length === 0) return false + } const def = ACTIONS[id] return !def.needsTarget || def.validTargets(s).length > 0 } diff --git a/src/sim/enemies.ts b/src/sim/enemies.ts new file mode 100644 index 0000000..e0154a2 --- /dev/null +++ b/src/sim/enemies.ts @@ -0,0 +1,116 @@ +import { Rng } from './rng' +import { add, bombAt, cellAt, eq } from './state' +import { CHARGE_IDS, DIRS, DIR_LIST, type Dir, type Drop, type Enemy, type EnemyKind, type GameEvent, type GameState, type Vec } from './types' + +/** Enemies walk on floor and are stopped by bombs. They don't block each other; they can share a tile. */ +export function enemyCanEnter(s: GameState, p: Vec): boolean { + return cellAt(s, p) === 'floor' && !bombAt(s, p) +} + +export function enemyAt(s: GameState, p: Vec): Enemy | undefined { + return s.enemies.find((e) => eq(e.pos, p)) +} + +interface Step { + dir: Dir + to: Vec +} + +function openSteps(s: GameState, e: Enemy): Step[] { + return DIR_LIST.map((dir) => ({ dir, to: add(e.pos, DIRS[dir]) })).filter((st) => enemyCanEnter(s, st.to)) +} + +/** Blob: carries on the way it was going three times in four, otherwise any open way. */ +function wander(s: GameState, e: Enemy, rng: Rng): Step | null { + const steps = openSteps(s, e) + const ahead = steps.find((st) => st.dir === e.dir) + if (ahead && rng.next() < 0.75) return ahead + return steps.length ? steps[rng.int(steps.length)]! : null +} + +/** Drifter: straight on until blocked, then any open way. */ +function drift(s: GameState, e: Enemy, rng: Rng): Step | null { + const steps = openSteps(s, e) + return steps.find((st) => st.dir === e.dir) ?? (steps.length ? steps[rng.int(steps.length)]! : null) +} + +/** The first step of a shortest path to the player over tiles enemies can enter, if there is one. */ +export function stepToward(s: GameState, e: Enemy): Step | null { + const key = (p: Vec) => p.y * s.width + p.x + const first = new Map<number, Step>() + const queue: Vec[] = [] + for (const st of openSteps(s, e)) { + if (eq(st.to, s.player)) return st + first.set(key(st.to), st) + queue.push(st.to) + } + for (let i = 0; i < queue.length; i++) { + const p = queue[i]! + for (const d of DIR_LIST) { + const q = add(p, DIRS[d]) + if (first.has(key(q)) || eq(q, e.pos) || !enemyCanEnter(s, q)) continue + first.set(key(q), first.get(key(p))!) + if (eq(q, s.player)) return first.get(key(q))! + queue.push(q) + } + } + return null +} + +const PLANNERS: Record<EnemyKind, (s: GameState, e: Enemy, rng: Rng) => Step | null> = { + blob: wander, + // Seeker: half the time a step along the shortest path to the player, otherwise a Blob's wander. + seeker: (s, e, rng) => (rng.next() < 0.5 ? stepToward(s, e) : null) ?? wander(s, e, rng), + drifter: drift, +} + +/** Phase 5: every enemy announces where it will step next turn. */ +export function planIntents(s: GameState): void { + const rng = new Rng(s.aiRng) + for (const e of s.enemies) { + const st = PLANNERS[e.kind](s, e, rng) + e.intent = st ? st.to : null + if (st) e.dir = st.dir + } + s.aiRng = rng.state +} + +/** + * Phase 2: every enemy steps where it announced. One whose tile was blocked this turn (a bomb + * placed there) stays put and does not re-plan. Frozen, they all stay put once. + */ +export function moveEnemies(s: GameState, events: GameEvent[]): void { + if (s.frozen) { + s.frozen = false + return + } + for (const e of s.enemies) { + if (!e.intent || !enemyCanEnter(s, e.intent)) continue + events.push({ type: 'enemyMoved', id: e.id, from: e.pos, to: e.intent }) + e.pos = e.intent + } +} + +/** Chance of a coin drop, and of an action-charge drop, per type (PLAN.md §10). To tune. */ +const DROP_CHANCE: Record<EnemyKind, { coins: number; charge: number }> = { + blob: { coins: 0.2, charge: 0.15 }, + seeker: { coins: 0.5, charge: 0.25 }, + drifter: { coins: 0.2, charge: 0.5 }, +} + +/** What a slain enemy leaves on its tile. Coins are worth 1 (60%), 2 (30%) or 5 (10%). */ +export function rollDrops(s: GameState, e: Enemy): Drop[] { + if (e.spawned) return [] + const rng = new Rng(s.dropRng) + const drops: Drop[] = [] + const chance = DROP_CHANCE[e.kind] + if (rng.next() < chance.coins) { + const r = rng.next() + drops.push({ pos: e.pos, kind: 'coins', amount: r < 0.6 ? 1 : r < 0.9 ? 2 : 5 }) + } + if (rng.next() < chance.charge) { + drops.push({ pos: e.pos, kind: CHARGE_IDS[rng.int(CHARGE_IDS.length)]! }) + } + s.dropRng = rng.state + return drops +} diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts index 324ab47..3c48cdf 100644 --- a/src/sim/levelgen.ts +++ b/src/sim/levelgen.ts @@ -1,5 +1,5 @@ import { stream } from './rng' -import type { Cell, Vec } from './types' +import { DIR_LIST, type Cell, type Dir, type EnemyKind, 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 @@ -7,12 +7,43 @@ export const LEVEL_HEIGHT = 13 export const START: Vec = { x: 1, y: 1 } /** Share of the open tiles that get a soft block. */ export const BLOCK_DENSITY = 0.35 +/** No enemy starts this close (Manhattan distance) to the player, and the exit isn't this close either. */ +export const SAFE_DISTANCE = 4 + +/** + * Which enemy types a level can have (PLAN.md §10): Blobs only at first, then one new type + * every 6 levels. Only the first three types exist so far. + */ +export function enemyKinds(level: number): EnemyKind[] { + const all: EnemyKind[] = ['blob', 'seeker', 'drifter'] + 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)) +} + +/** 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 interface EnemyStart { + kind: EnemyKind + pos: Vec + dir: Dir +} export interface Layout { width: number height: number cells: Cell[] start: Vec + /** Always under a soft block. */ + exit: Vec + enemies: EnemyStart[] + par: number } export function generateLevel(seed: number, level: number): Layout { @@ -36,7 +67,20 @@ export function generateLevel(seed: number, level: number): Layout { if (cells[i] === 'floor' && !clear.has(key({ x, y })) && rng.next() < BLOCK_DENSITY) cells[i] = 'block' } } - return { width, height, cells, start: { ...START } } + + 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 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--) { + 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) } } function key(p: Vec): string { diff --git a/src/sim/record.ts b/src/sim/record.ts index 3824fa5..4205197 100644 --- a/src/sim/record.ts +++ b/src/sim/record.ts @@ -6,22 +6,24 @@ 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 = 1 +export const SIM_VERSION = 2 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */ export interface RunRecord { simVersion: number seed: number startLevel: number + /** Action charges of each kind given at the start (testing aid; 0 in a normal run). */ + startCharges: number actions: Action[] } -export function newRecord(seed: number, startLevel = 1): RunRecord { - return { simVersion: SIM_VERSION, seed, startLevel, actions: [] } +export function newRecord(seed: number, startLevel = 1, startCharges = 0): RunRecord { + return { simVersion: SIM_VERSION, seed, startLevel, startCharges, actions: [] } } export function replay(record: RunRecord): GameState { - let s = newRun(record.seed, record.startLevel) + let s = newRun(record.seed, record.startLevel, record.startCharges) 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 325b6e1..fe03ce8 100644 --- a/src/sim/state.ts +++ b/src/sim/state.ts @@ -1,17 +1,58 @@ +import { planIntents } from './enemies' import { generateLevel } from './levelgen' -import type { Bomb, Cell, GameState, Stats, Vec } from './types' +import { hashSeed } from './rng' +import type { Bomb, Cell, ChargeId, GameState, Stats, Vec } from './types' export const START_LIVES = 3 export const START_STATS: Stats = { capacity: 1, fire: 1, fuse: 3 } -export function newRun(seed: number, level = 1): GameState { - return createLevel(seed, level, START_LIVES, { ...START_STATS }) +/** Coins awarded for clearing a level within its par. */ +export const PAR_BONUS = 5 + +/** What the player takes from one attempt at a level into the next. */ +export interface Carry { + lives: number + stats: Stats + coins: number + charges: Record<ChargeId, number> +} + +export function noCharges(): Record<ChargeId, number> { + return { kick: 0, throw: 0, shield: 0, freeze: 0 } +} + +/** `charges`: start with this many of every action charge (a testing aid, `?charges=` in the URL). */ +export function newRun(seed: number, level = 1, charges = 0): GameState { + const all = { kick: charges, throw: charges, shield: charges, freeze: charges } + return createLevel(seed, level, { lives: START_LIVES, stats: { ...START_STATS }, coins: 0, charges: all }) +} + +/** + * 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. + */ +export function createLevel(seed: number, level: number, carry: Carry): GameState { + const { width, height, cells, start, exit, 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 }, + bombs: [], status: 'playing', + enemies: enemies.map((e, id) => ({ id, kind: e.kind, pos: e.pos, dir: e.dir, intent: null, spawned: false })), + nextEnemyId: enemies.length, + drops: [], exit, par, shield: false, frozen: false, + 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 } } -/** A fresh attempt at a level: the layout comes from the seed, the player keeps lives and stats. */ -export function createLevel(seed: number, level: number, lives: number, stats: Stats): GameState { - const { width, height, cells, start } = generateLevel(seed, level) - return { seed, level, turn: 0, width, height, cells, player: start, lives, stats, bombs: [], status: 'playing' } +/** The exit can be used once its block is gone and every enemy is dead. */ +export function exitOpen(s: GameState): boolean { + return s.enemies.length === 0 && cellAt(s, s.exit) === 'floor' } export function cloneState(s: GameState): GameState { diff --git a/src/sim/turn.ts b/src/sim/turn.ts index 5ae92a5..454fa75 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -1,6 +1,8 @@ -import { ACTIONS, isValidTarget } from './actions' +import { ACTIONS, isAvailable, isCharge, isValidTarget } from './actions' +import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies' +import { enemyKinds } from './levelgen' import { detonate, tickBombs } from './explosions' -import { cloneState, createLevel, idx } from './state' +import { carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, PAR_BONUS } from './state' import type { Action, GameEvent, GameState } from './types' export interface StepResult { @@ -8,6 +10,9 @@ export interface StepResult { events: GameEvent[] } +/** How many enemies come out of an exit caught in a flame. */ +export const EXIT_SPAWN = 3 + /** * One turn (PLAN.md §1). Never mutates `prev`. Throws on an action that isn't allowed right * now; the UI only offers allowed ones, so a throw is a bug. @@ -17,34 +22,107 @@ export function step(prev: GameState, action: Action): StepResult { const events: GameEvent[] = [] if (action.id === 'continue') { - if (s.status !== 'dead') throw new Error(`continue while ${s.status}`) - events.push({ type: 'levelRestarted' }) - return { state: createLevel(s.seed, s.level, s.lives, s.stats), events } + if (s.status === 'dead') { + events.push({ type: 'levelRestarted' }) + return { state: createLevel(s.seed, s.level, carryOf(s)), events } + } + if (s.status === 'cleared') { + events.push({ type: 'levelStarted', level: s.level + 1 }) + return { state: createLevel(s.seed, s.level + 1, carryOf(s)), events } + } + throw new Error(`continue while ${s.status}`) } - if (s.status !== 'playing') throw new Error(`${action.id} while ${s.status}`) + if (!isAvailable(s, action.id)) throw new Error(`${action.id} is not available`) - // Phase 1: the player acts. + // Phase 1: the player acts, and picks up whatever is where they end up. const def = ACTIONS[action.id] if (def.needsTarget && (!action.target || !isValidTarget(s, action.id, action.target))) { throw new Error(`invalid target for ${action.id}: ${JSON.stringify(action.target)}`) } + if (isCharge(action.id)) { + s.charges[action.id]-- + events.push({ type: 'chargeUsed', id: action.id }) + } def.apply(s, action.target, events) + pickUp(s, 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' + events.push({ type: 'levelCleared', level: s.level, turns: s.turn, parBonus }) + return { state: s, events } + } + + // A shield absorbs every hit for the rest of the turn it breaks in. + let shielded = false + const hurt = (): void => { + if (shielded) return + if (s.shield) { + s.shield = false + shielded = true + events.push({ type: 'shieldBroke', pos: s.player }) + return + } + s.lives-- + 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() - // Phase 2: enemies carry out their announced intents (M5). + // Phase 2: enemies carry out their announced intents. Second contact check. + if (s.status === 'playing') { + moveEnemies(s, events) + if (enemyAt(s, s.player)) hurt() + } // Phase 3: bombs tick; those at 0 explode, in chains. + const exitWasOpen = cellAt(s, s.exit) === 'floor' + const enemiesBefore = s.enemies.length tickBombs(s, events) const burned = detonate(s, events) - // Phase 4: damage. - if (burned.has(idx(s, s.player))) { - s.lives-- - s.status = s.lives > 0 ? 'dead' : 'gameOver' - events.push({ type: 'playerDied', pos: s.player, livesLeft: s.lives }) + // Phase 4: damage. Flame burns the player, enemies, and drops lying in the open; slain enemies + // then leave their own drops, so those survive the flame that made them. + if (s.status === 'playing' && burned.has(idx(s, s.player))) hurt() + for (const d of s.drops.filter((d) => burned.has(idx(s, d.pos)))) { + s.drops.splice(s.drops.indexOf(d), 1) + events.push({ type: 'dropBurned', drop: d }) + } + for (const e of s.enemies.filter((e) => burned.has(idx(s, e.pos)))) { + 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)) { + s.drops.push(drop) + events.push({ type: 'dropSpawned', drop }) + } } + if (!exitWasOpen && cellAt(s, s.exit) === 'floor') events.push({ type: 'exitRevealed', pos: s.exit }) + // Flaming an uncovered exit lets out a group of the level's toughest type (NES rule, PLAN.md §5). + if (exitWasOpen && burned.has(idx(s, s.exit))) { + const kinds = enemyKinds(s.level) + for (let n = 0; n < EXIT_SPAWN; n++) { + s.enemies.push({ id: s.nextEnemyId++, kind: kinds[kinds.length - 1]!, pos: { ...s.exit }, dir: 'up', intent: null, spawned: true }) + } + events.push({ type: 'enemiesSpawned', pos: s.exit, count: EXIT_SPAWN }) + } + if (enemiesBefore > 0 && s.enemies.length === 0) events.push({ type: 'exitOpened', pos: s.exit }) - // Phase 5: enemies announce their next intents (M5). + // Phase 5: enemies announce their next intents. + planIntents(s) s.turn++ 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))) { + 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 }) + } +} diff --git a/src/sim/types.ts b/src/sim/types.ts index b6d0c66..8ab9c96 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -41,8 +41,35 @@ export interface Stats { fuse: number } -/** `dead`: lost a life and waiting for `continue`. `gameOver`: no lives left. */ -export type Status = 'playing' | 'dead' | 'gameOver' +/** + * `dead`: lost a life and waiting for `continue`. `gameOver`: no lives left. + * `cleared`: stepped onto the open exit; `continue` goes to the next level. + */ +export type Status = 'playing' | 'dead' | 'gameOver' | 'cleared' + +/** The enemy types so far (PLAN.md §10). */ +export type EnemyKind = 'blob' | 'seeker' | 'drifter' + +export interface Enemy { + /** Stable for the enemy's life, so the renderer can follow it between turns. */ + id: number + kind: EnemyKind + pos: Vec + /** The way it last moved or meant to move. Blobs and Drifters keep going this way. */ + dir: Dir + /** Where it will step next turn (phase 2), announced in phase 5. Null: it stays put. */ + intent: Vec | null + /** Spawned as a punishment (the exit was flamed): drops nothing. */ + spawned: boolean +} + +/** The consumable actions dropped by enemies (PLAN.md §3.3). The first four of the plan's list. */ +export type ChargeId = 'kick' | 'throw' | 'shield' | 'freeze' + +export const CHARGE_IDS: readonly ChargeId[] = ['kick', 'throw', 'shield', 'freeze'] + +/** Something lying on a floor tile to walk over and collect: coins, or one action charge. */ +export type Drop = { pos: Vec; kind: 'coins'; amount: number } | { pos: Vec; kind: ChargeId } export interface GameState { seed: number @@ -58,10 +85,26 @@ export interface GameState { stats: Stats bombs: Bomb[] status: Status + enemies: Enemy[] + nextEnemyId: number + drops: Drop[] + /** Under a soft block until that block burns. Open (walkable to win) once no enemies are left. */ + exit: Vec + /** Finishing the level in at most this many turns pays PAR_BONUS coins. */ + par: number + coins: number + charges: Record<ChargeId, number> + /** Shield armed: the next hit (flame or enemy) is absorbed. */ + shield: boolean + /** Enemies skip their next move (Freeze). */ + frozen: boolean + /** The AI and drop random streams' positions (`Rng.state`), so a turn is a pure function of the state. */ + aiRng: number + dropRng: number } /** The actions the player can pick on the board. */ -export type ActionId = 'move' | 'bomb' | 'wait' +export type ActionId = 'move' | 'bomb' | 'wait' | ChargeId export type Action = { id: ActionId; target?: Vec } | { id: 'continue' } @@ -75,3 +118,16 @@ export type GameEvent = | { type: 'blockDestroyed'; pos: Vec } | { type: 'playerDied'; pos: Vec; livesLeft: number } | { type: 'levelRestarted' } + | { type: 'enemyMoved'; id: number; from: Vec; to: Vec } + | { type: 'enemyDied'; id: number; pos: Vec } + | { type: 'enemiesSpawned'; pos: Vec; count: number } + | { type: 'dropSpawned'; drop: Drop } + | { type: 'dropBurned'; drop: Drop } + | { type: 'pickedUp'; drop: Drop } + | { type: 'exitRevealed'; pos: Vec } + | { type: 'exitOpened'; pos: Vec } + | { type: 'levelCleared'; level: number; turns: number; parBonus: number } + | { type: 'levelStarted'; level: number } + | { type: 'chargeUsed'; id: ChargeId } + | { type: 'bombKicked'; from: Vec; to: Vec } + | { type: 'shieldBroke'; pos: Vec } diff --git a/src/style.css b/src/style.css index d74d693..3d8d85e 100644 --- a/src/style.css +++ b/src/style.css @@ -44,9 +44,10 @@ img { right: 0; z-index: 2; display: flex; + flex-wrap: wrap; justify-content: space-between; align-items: center; - gap: 8px; + gap: 4px 12px; padding: calc(env(safe-area-inset-top) + 6px) 16px 6px; background: var(--ink); color: #fff; @@ -78,6 +79,12 @@ img { margin-left: 10px; } +#hud small.par { + margin: 0 0 0 6px; + font-size: 12px; + opacity: 0.7; +} + #hud small { margin-right: 4px; font-size: 13px; @@ -93,21 +100,22 @@ img { right: 0; z-index: 2; display: flex; + flex-wrap: wrap; gap: 8px; - overflow-x: auto; padding: 8px 16px calc(env(safe-area-inset-bottom) + 8px); background: var(--ink); } #actions button { - flex: 1 0 auto; + /* Three to a row, so Move / Bomb / Wait fill the first and any charges wrap below. */ + flex: 1 0 calc(33.333% - 6px); display: grid; grid-template-columns: auto auto; grid-template-rows: auto auto; align-items: center; justify-content: center; column-gap: 6px; - min-width: 72px; + min-width: 0; min-height: 56px; margin-bottom: 4px; border: 3px solid var(--ink); @@ -126,6 +134,58 @@ img { height: 36px; } +#actions button[hidden] { + display: none; +} + +/* Action charges: a second, smaller row under Move / Bomb / Wait. */ +#actions button.charge { + /* Quarter width whatever their number, so a single charge isn't a full-width bar. */ + flex: 0 0 calc(25% - 6px); + min-width: 0; + min-height: 44px; + margin-top: 6px; + font-size: 13px; +} + +#actions button.charge .icon { + width: 26px; + height: 26px; +} + +#actions button { + position: relative; +} + +#actions .count { + position: absolute; + top: -12px; + right: -6px; + min-width: 20px; + padding: 0 4px; + border: 2px solid var(--ink); + border-radius: 11px; + background: #ffc83d; + color: var(--ink); + font-size: 12px; + font-weight: 800; +} + +/* On a phone, four charge buttons share a row: no room for their key hints. */ +@media (max-width: 600px) { + #actions button.charge { + grid-template-rows: auto; + } + + #actions button.charge kbd { + display: none; + } + + #actions button.charge .icon { + grid-row: auto; + } +} + #actions button:active:not(:disabled) { transform: translateY(4px); box-shadow: none; diff --git a/src/ui/controls.ts b/src/ui/controls.ts index c3be5e9..8f0595b 100644 --- a/src/ui/controls.ts +++ b/src/ui/controls.ts @@ -1,18 +1,22 @@ -import { ACTIONS, ACTION_ORDER, isAvailable } from '../sim/actions' -import { START_LIVES } from '../sim/state' +import { ACTIONS, ACTION_ORDER, isAvailable, isCharge } from '../sim/actions' +import { PAR_BONUS, START_LIVES } from '../sim/state' import type { ActionId, GameState } from '../sim/types' -import { spriteUrl } from '../render/sprites' +import { CHARGE_ICONS, spriteUrl } from '../render/sprites' -/** The HTML around the board: HUD, action bar, and the banner shown on death. */ +/** 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 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. */ banner(): void } -const KEYS: Record<ActionId, string> = { move: 'WASD', bomb: 'B', wait: 'Space' } -const ICONS: Record<ActionId, string> = { move: 'character_green_walk_a', bomb: 'bomb', wait: 'character_green_duck' } +export const KEYS: Record<ActionId, string> = { + move: 'WASD', bomb: 'B', wait: 'Space', kick: 'K', throw: 'T', shield: 'H', freeze: 'F', +} +const ICONS: Record<ActionId, string> = { + move: 'character_green_walk_a', bomb: 'bomb', wait: 'character_green_duck', ...CHARGE_ICONS, +} function icon(name: string, className = 'icon'): string { return `<img class="${className}" src="${spriteUrl(name)}" alt="" draggable="false">` @@ -30,7 +34,9 @@ export function createControls(handlers: Handlers) { const b = document.createElement('button') b.type = 'button' b.dataset.action = id - b.innerHTML = `${icon(ICONS[id])}<span>${ACTIONS[id].label}</span><kbd>${KEYS[id]}</kbd>` + if (isCharge(id)) b.classList.add('charge') + b.innerHTML = `${icon(ICONS[id])}<span class="label">${ACTIONS[id].label}</span><kbd>${KEYS[id]}</kbd>` + if (isCharge(id)) b.insertAdjacentHTML('beforeend', '<span class="count"></span>') b.addEventListener('click', () => handlers.pick(id)) bar.append(b) buttons.set(id, b) @@ -46,6 +52,8 @@ export function createControls(handlers: Handlers) { lives: document.getElementById('hud-lives')!, level: document.getElementById('hud-level')!, turn: document.getElementById('hud-turn')!, + enemies: document.getElementById('hud-enemies')!, + coins: document.getElementById('hud-coins')!, stats: document.getElementById('hud-stats')!, } @@ -54,6 +62,11 @@ export function createControls(handlers: Handlers) { for (const [id, b] of buttons) { b.disabled = !isAvailable(s, id) b.setAttribute('aria-pressed', String(selected === id)) + // Charges only get a button while the player holds some; a badge shows how many. + if (isCharge(id)) { + b.hidden = s.charges[id] === 0 + b.querySelector('.count')!.textContent = `×${s.charges[id]}` + } } // 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) => @@ -61,19 +74,28 @@ export function createControls(handlers: Handlers) { ) hud.lives.innerHTML = `<span class="sr-only">${s.lives} lives</span>${hearts.join('')}` hud.level.innerHTML = icon('flag_green_a') + number(s.level, 'Level ') - hud.turn.innerHTML = `<small aria-hidden="true">Turn</small>` + number(s.turn, 'Turn ') + hud.enemies.innerHTML = icon('slime_normal_rest') + number(s.enemies.length, 'Enemies left ') + hud.coins.innerHTML = icon('hud_coin') + number(s.coins, 'Coins ') + // 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>` hud.stats.innerHTML = icon('bomb') + number(s.stats.capacity, 'Bombs ') + icon('fireball') + number(s.stats.fire, ', fire ') banner.hidden = s.status === 'playing' if (s.status === 'dead') { - bannerText.textContent = `Boom. ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.` + bannerText.textContent = `Ouch! ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.` bannerButton.textContent = 'Try again' bannerIcon.src = spriteUrl('character_green_hit') } else if (s.status === 'gameOver') { bannerText.textContent = `Game over on level ${s.level}. Seed ${s.seed}.` bannerButton.textContent = 'New run' bannerIcon.src = spriteUrl('hud_heart_empty') + } else if (s.status === 'cleared') { + const bonus = s.turn <= s.par ? ` Under par: +${PAR_BONUS} coins!` : '' + bannerText.textContent = `Level ${s.level} cleared in ${s.turn} turns.${bonus}` + bannerButton.textContent = 'Next level' + bannerIcon.src = spriteUrl('flag_green_a') } }, /** True if the banner is showing; Enter presses its button. */ diff --git a/test/danger.test.ts b/test/danger.test.ts index 1d0c4e3..ed6ece8 100644 --- a/test/danger.test.ts +++ b/test/danger.test.ts @@ -7,9 +7,9 @@ import { step } from '../src/sim/turn' import type { Action, GameState } from '../src/sim/types' import { Rng } from '../src/sim/rng' -/** A random but legal action; `continue` when dead. */ +/** A random but legal action; `continue` when dead or the level is cleared. */ function randomAction(s: GameState, rng: Rng): Action { - if (s.status === 'dead') return { id: 'continue' } + if (s.status === 'dead' || s.status === 'cleared') return { id: 'continue' } const ids = ACTION_ORDER.filter((id) => isAvailable(s, id)) const id = ids[rng.int(ids.length)]! const targets = ACTIONS[id].validTargets(s) @@ -26,7 +26,7 @@ describe('danger overlay', () => { it('"now" is exactly what burns if the player waits, and "next" what burns the turn after', () => { const rng = new Rng(7) for (let seed = 1; seed <= 20; seed++) { - let s = newRun(seed) + let s = newRun(seed, 1 + (seed % 20), 1) for (let t = 0; t < 150 && s.status !== 'gameOver'; t++) { if (s.status === 'playing') { const danger = predictDanger(s) @@ -43,8 +43,9 @@ describe('danger overlay', () => { describe('run record', () => { it('replays to the identical state', () => { const rng = new Rng(99) - const record = newRecord(1234) - let s = newRun(record.seed) + // With charges, so kicks, throws, shields and freezes are in the mix too. + const record = newRecord(1234, 1, 2) + let s = newRun(record.seed, 1, 2) for (let t = 0; t < 300 && s.status !== 'gameOver'; t++) { const a = randomAction(s, rng) record.actions.push(a) diff --git a/test/enemies.test.ts b/test/enemies.test.ts new file mode 100644 index 0000000..5458470 --- /dev/null +++ b/test/enemies.test.ts @@ -0,0 +1,355 @@ +import { describe, expect, it } from 'vitest' +import { ACTIONS, isAvailable } from '../src/sim/actions' +import { planIntents, stepToward } from '../src/sim/enemies' +import { enemyCount, enemyKinds, generateLevel, SAFE_DISTANCE, START } from '../src/sim/levelgen' +import { newRun, PAR_BONUS } from '../src/sim/state' +import { EXIT_SPAWN, step } from '../src/sim/turn' +import type { GameState, Vec } from '../src/sim/types' +import { board, v } from './helpers' + +const dist = (a: Vec, b: Vec) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y) + +describe('levelgen: enemies and exit', () => { + for (let seed = 1; seed <= 100; seed++) { + const level = 1 + (seed % 50) + it(`seed ${seed}: exit under a block, enemies on floor away from the start`, () => { + const g = generateLevel(seed, level) + const at = (p: Vec) => g.cells[p.y * g.width + p.x] + expect(at(g.exit)).toBe('block') + expect(dist(g.exit, START)).toBeGreaterThan(SAFE_DISTANCE) + expect(g.enemies).toHaveLength(enemyCount(level)) + expect(new Set(g.enemies.map((e) => `${e.pos.x},${e.pos.y}`)).size).toBe(g.enemies.length) + for (const e of g.enemies) { + expect(at(e.pos)).toBe('floor') + expect(dist(e.pos, START)).toBeGreaterThan(SAFE_DISTANCE) + expect(enemyKinds(level)).toContain(e.kind) + } + }) + } + + it('brings in a new type every 6 levels', () => { + expect(enemyKinds(1)).toEqual(['blob']) + expect(enemyKinds(6)).toEqual(['blob']) + expect(enemyKinds(7)).toEqual(['blob', 'seeker']) + expect(enemyKinds(13)).toEqual(['blob', 'seeker', 'drifter']) + expect(enemyKinds(50)).toEqual(['blob', 'seeker', 'drifter']) + }) + + it('a new level already shows every enemy intent', () => { + const s = newRun(5) + expect(s.enemies.length).toBeGreaterThan(0) + expect(s.enemies.some((e) => e.intent)).toBe(true) + }) +}) + +/** Give every enemy an intent of one step in a direction. */ +function intend(s: GameState, ...targets: Vec[]): GameState { + s.enemies.forEach((e, i) => (e.intent = targets[i]!)) + return s +} + +describe('turn order with enemies', () => { + it('enemies move before bombs explode: one walking into a blast dies', () => { + const s = intend(board(` + ####### + #P.1.B# + #######`), v(4, 1)) + const { state, events } = step(s, { id: 'wait' }) + expect(events.map((e) => e.type).slice(0, 5)).toEqual(['waited', 'enemyMoved', 'bombTicked', 'exploded', 'enemyDied']) + expect(state.enemies).toEqual([]) + }) + + it('an enemy whose tile got a bomb this turn stays put and does not re-plan', () => { + const s = intend(board(` + ###### + #P.B.# + ######`), v(2, 1)) + const { state, events } = step(s, { id: 'bomb', target: v(2, 1) }) + expect(state.enemies[0]!.pos).toEqual(v(3, 1)) + expect(events.some((e) => e.type === 'enemyMoved')).toBe(false) + }) + + it('walking into an enemy kills the player', () => { + const s = board(` + ##### + #PB.# + #####`) + const { state } = step(s, { id: 'move', target: v(2, 1) }) + expect(state.status).toBe('dead') + }) + + it('an enemy walking into the player kills them', () => { + const s = intend(board(` + ##### + #PB.# + #####`), v(1, 1)) + expect(step(s, { id: 'wait' }).state.status).toBe('dead') + }) + + it('swapping tiles with an enemy counts as contact', () => { + const s = intend(board(` + ##### + #PB.# + #####`), v(1, 1)) + expect(step(s, { id: 'move', target: v(2, 1) }).state.status).toBe('dead') + }) + + it('a bomb cannot go on an enemy', () => { + const s = board(` + ##### + #PB.# + #.### + #####`) + expect(ACTIONS.bomb.validTargets(s)).toEqual([v(1, 2)]) + }) +}) + +describe('enemy movement', () => { + it('a Seeker can find the shortest way round', () => { + const s = board(` + ####### + #P....# + ####.## + #S....# + #######`) + expect(stepToward(s, s.enemies[0]!)?.to).toEqual(v(2, 3)) + }) + + it('a Drifter keeps going straight until blocked, then turns', () => { + const s = board(` + ##### + #D..# + #.#.# + #P..# + #####`) + s.enemies[0]!.kind = 'drifter' + planIntents(s) + expect(s.enemies[0]!.intent).toEqual(v(2, 1)) + s.enemies[0]!.pos = v(3, 1) + s.cells[1 * s.width + 2] = 'block' // behind it, so the only way on is down + planIntents(s) + expect(s.enemies[0]!.intent).toEqual(v(3, 2)) + expect(s.enemies[0]!.dir).toBe('down') + }) + + it('an enemy with nowhere to go stays put', () => { + const s = board(` + ##### + #B#P# + #####`) + planIntents(s) + expect(s.enemies[0]!.intent).toBeNull() + }) + + it('the same state always plans the same intents', () => { + const a = newRun(77, 13) + const b = newRun(77, 13) + expect(a.enemies).toEqual(b.enemies) + expect(a.aiRng).toBe(b.aiRng) + }) +}) + +describe('drops', () => { + it('walking onto coins collects them', () => { + const s = board(` + ##### + #P$.# + #####`) + const { state, events } = step(s, { id: 'move', target: v(2, 1) }) + expect(state.coins).toBe(1) + expect(state.drops).toEqual([]) + expect(events[1]).toEqual({ type: 'pickedUp', drop: { pos: v(2, 1), kind: 'coins', amount: 1 } }) + }) + + it('walking onto a charge adds one to that action', () => { + const s = board(` + ##### + #P..# + #####`) + s.drops.push({ pos: v(2, 1), kind: 'kick' }) + expect(step(s, { id: 'move', target: v(2, 1) }).state.charges.kick).toBe(1) + }) + + it('flame burns drops lying in the open, but not the ones its own kills leave', () => { + let found = false + // Try drop streams until a slain enemy leaves something; the old coin always burns. + for (let r = 1; r < 200 && !found; r++) { + const s = board(` + ####### + #P.$1B# + #######`) + s.dropRng = r + const { state, events } = step(s, { id: 'wait' }) + expect(events.some((e) => e.type === 'dropBurned')).toBe(true) + expect(state.drops.some((d) => d.kind === 'coins' && d.pos.x === 3)).toBe(false) + if (state.drops.length) { + found = true + expect(state.drops.every((d) => d.pos.x === 5)).toBe(true) + } + } + expect(found).toBe(true) + }) +}) + +describe('the exit', () => { + it('a block over the exit burns away and reveals it', () => { + const s = board(` + ###### + #P.1e# + ######`) + const { events } = step(s, { id: 'wait' }) + expect(events).toContainEqual({ type: 'exitRevealed', pos: v(4, 1) }) + }) + + it('stays shut while any enemy lives', () => { + const s = board(` + ###### + #PE.B# + ######`) + const { state } = step(s, { id: 'move', target: v(2, 1) }) + expect(state.status).toBe('playing') + }) + + it('opens when the last enemy dies, and stepping on it clears the level with a par bonus', () => { + let s = board(` + ####### + #PE.1B# + #######`) + const r = step(s, { id: 'wait' }) + expect(r.events).toContainEqual({ type: 'exitOpened', pos: v(2, 1) }) + s = r.state + const cleared = step(s, { id: 'move', target: v(2, 1) }) + expect(cleared.state.status).toBe('cleared') + expect(cleared.state.coins).toBe(PAR_BONUS) + expect(cleared.events.at(-1)).toEqual({ type: 'levelCleared', level: 1, turns: 7, parBonus: PAR_BONUS }) + }) + + it('no par bonus over par', () => { + const s = { ...board(` + #### + #PE# + ####`), turn: 100, par: 50 } + const { state } = step(s, { id: 'move', target: v(2, 1) }) + expect(state.status).toBe('cleared') + expect(state.coins).toBe(0) + }) + + it('continue after clearing starts the next level, carrying lives, stats, coins and charges', () => { + const s = { ...board(` + #### + #PE# + ####`, { fire: 3 }), coins: 4, lives: 2 } + s.charges.kick = 2 + const cleared = step(s, { id: 'move', target: v(2, 1) }).state + const { state, events } = step(cleared, { id: 'continue' }) + expect(events).toEqual([{ type: 'levelStarted', level: 2 }]) + expect(state).toMatchObject({ level: 2, turn: 0, lives: 2, coins: 4 + PAR_BONUS, status: 'playing' }) + expect(state.stats.fire).toBe(3) + expect(state.charges.kick).toBe(2) + expect(state.enemies.length).toBe(enemyCount(2)) + }) + + it('flaming the uncovered exit lets out enemies that drop nothing', () => { + const s = board(` + ###### + #P.1E# + ######`) + const { state, events } = step(s, { id: 'wait' }) + expect(events).toContainEqual({ type: 'enemiesSpawned', pos: v(4, 1), count: EXIT_SPAWN }) + expect(state.enemies).toHaveLength(EXIT_SPAWN) + expect(state.enemies.every((e) => e.spawned && e.kind === 'blob')).toBe(true) + }) +}) + +describe('action charges', () => { + it('are only offered while held, and using one spends it', () => { + const s = board(` + ###### + #P3..# + ######`) + expect(isAvailable(s, 'kick')).toBe(false) + s.charges.kick = 1 + expect(isAvailable(s, 'kick')).toBe(true) + const { state } = step(s, { id: 'kick', target: v(2, 1) }) + expect(state.charges.kick).toBe(0) + }) + + it('kick: the bomb slides until the next tile cannot hold it, keeping its timer', () => { + const s = board(` + ####### + #P3..B# + #######`) + s.charges.kick = 1 + const { state, events } = step(s, { id: 'kick', target: v(2, 1) }) + expect(events).toContainEqual({ type: 'bombKicked', from: v(2, 1), to: v(4, 1) }) + expect(state.bombs[0]).toMatchObject({ pos: v(4, 1), timer: 2 }) + }) + + it('kick: a bomb with no room to slide is not a target', () => { + const s = board(` + ##### + #P3+# + #####`) + s.charges.kick = 1 + expect(ACTIONS.kick.validTargets(s)).toEqual([]) + }) + + it('throw: exactly 2 tiles away, over a wall', () => { + const s = board(` + ###### + #P#..# + #.#### + #.#### + ######`) + s.charges.throw = 1 + expect(ACTIONS.throw.validTargets(s)).toEqual([v(1, 3), v(3, 1)]) + const { state } = step(s, { id: 'throw', target: v(3, 1) }) + expect(state.bombs).toEqual([{ pos: v(3, 1), timer: 3, fire: 1, placedTurn: 5 }]) + }) + + it('throw: needs bomb capacity', () => { + const s = board(` + ####### + #P...3# + #######`) + s.charges.throw = 1 + expect(isAvailable(s, 'throw')).toBe(false) + }) + + it('shield: absorbs a hit, then is gone', () => { + const s = board(` + ##### + #P1.# + #####`) + s.charges.shield = 1 + const r = step(s, { id: 'shield' }) + expect(r.state.status).toBe('playing') + expect(r.events).toContainEqual({ type: 'shieldBroke', pos: v(1, 1) }) + expect(r.state.shield).toBe(false) + expect(r.state.lives).toBe(3) + }) + + it('shield: absorbs every hit in the turn it breaks (enemy, then flame)', () => { + const s = intend(board(` + ##### + #PB.# + #1### + #####`), v(1, 1)) + s.shield = true + const { state } = step(s, { id: 'wait' }) + expect(state.status).toBe('playing') + expect(state.lives).toBe(3) + expect(state.enemies).toEqual([]) // the flame got the Blob on the player's tile + }) + + it('freeze: enemies skip the move they announced', () => { + const s = intend(board(` + ###### + #P..B# + ######`), v(3, 1)) + s.charges.freeze = 1 + const { state } = step(s, { id: 'freeze' }) + expect(state.enemies[0]!.pos).toEqual(v(4, 1)) + expect(state.frozen).toBe(false) + }) +}) diff --git a/test/helpers.ts b/test/helpers.ts index ece641f..c59abc7 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,9 +1,14 @@ -import { START_LIVES, START_STATS } from '../src/sim/state' -import type { Cell, GameState, Stats, Vec } from '../src/sim/types' +import { noCharges, 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' } /** * A state from a picture: `#` wall, `+` soft block, `.` floor, `P` player (on floor), - * `1`–`9` a bomb with that timer (on floor, placed on an earlier turn). + * `1`–`9` a bomb with that timer (on floor, placed on an earlier turn), + * `B` / `S` / `D` a Blob / Seeker / Drifter facing right with no intent (it stays put unless the + * test sets one), `E` the exit uncovered, `e` the exit under a soft block, `$` a 1-coin drop. + * Without `E` or `e` the exit is off the board, so it never opens. */ export function board(picture: string, stats: Partial<Stats> = {}): GameState { const rows = picture.trim().split('\n').map((r) => r.trim()) @@ -14,13 +19,19 @@ export function board(picture: string, stats: Partial<Stats> = {}): GameState { const s: GameState = { seed: 1, level: 1, turn: 5, width, height, cells, player: { x: 0, y: 0 }, lives: START_LIVES, stats: { ...START_STATS, ...stats }, bombs: [], status: 'playing', + enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: 100, coins: 0, + charges: noCharges(), shield: false, frozen: false, aiRng: 1, dropRng: 1, } rows.forEach((row, y) => { if (row.length !== width) throw new Error(`row ${y} is ${row.length} wide, not ${width}`) ;[...row].forEach((ch, x) => { - cells.push(ch === '#' ? 'wall' : ch === '+' ? 'block' : 'floor') + cells.push(ch === '#' ? 'wall' : ch === '+' || ch === 'e' ? 'block' : 'floor') if (ch === 'P') player = { x, y } if (/[1-9]/.test(ch)) s.bombs.push({ pos: { x, y }, timer: Number(ch), fire: s.stats.fire, placedTurn: 0 }) + const kind = ENEMIES[ch] + if (kind) s.enemies.push({ id: s.nextEnemyId++, kind, pos: { x, y }, dir: 'right', intent: null, spawned: false }) + if (ch === 'E' || ch === 'e') s.exit = { x, y } + if (ch === '$') s.drops.push({ pos: { x, y }, kind: 'coins', amount: 1 }) }) }) if (!player) throw new Error('no P in the picture') diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index 89635cb..f341cee 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -30,15 +30,19 @@ const SHOTS = [ // Standing next to our own bomb as it goes off. { name: 'death', act: press('KeyB', 'KeyD', 'Space', 'Space', 'Space') }, { name: 'desktop', viewport: DESKTOP, act: press('KeyB', 'KeyD', 'KeyS') }, + // Level 13 has all three enemy types so far, each with its intent arrow. + { name: 'enemies', query: 'level=13', act: press('KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD') }, + // 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]') } }, ] mkdirSync(OUT, { recursive: true }) const server = await preview({ preview: { port: 4179, strictPort: true } }) const browser = await chromium.launch() try { - for (const { name, viewport = PHONE, act } of SHOTS) { + 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') + await page.goto(`http://localhost:4179/?seed=1&${query}`) await page.waitForSelector('canvas') await page.waitForTimeout(300) await act(page)