explodorama / commit
Sprinter, Ghost, Wisp and Hunter; a new enemy type every 3 levels
CHANGELOG.md | 1 + PLAN.md | 19 +++- README.md | 33 ++++--- decisions.md | 23 +++++ docs/screenshots/almanac-enemies.png | Bin 195003 -> 314698 bytes docs/screenshots/enemies.png | Bin 217399 -> 213010 bytes docs/screenshots/menu.png | Bin 144018 -> 145160 bytes src/render/board.ts | 19 +++- src/render/sprites.ts | 17 ++++ src/sim/demo.ts | 2 +- src/sim/enemies.ts | 135 ++++++++++++++++++++------ src/sim/levelgen.ts | 10 +- src/sim/path.ts | 8 +- src/sim/record.ts | 2 +- src/sim/types.ts | 10 +- src/ui/almanac.ts | 4 +- src/ui/info.ts | 17 +++- test/enemies.test.ts | 14 ++- test/helpers.ts | 7 +- test/new-enemies.test.ts | 177 +++++++++++++++++++++++++++++++++++ tools/screenshots.mjs | 2 +- 21 files changed, 425 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d0e5bd..bfc5c6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ saved before the change is dropped and the next visit starts a new run. ### Added +- **Four new enemies:** the **Sprinter** (rests, then dashes 2 tiles), the **Ghost** (every other turn, chasing you through crates), the **Wisp** (wanders through crates) and the **Hunter** (always chases). A new type now comes every 3 levels, so a run meets all seven. Version 28: runs saved before don't resume. - **A frame-rate meter** (`?fps`), and `?gl=0`, `?ratio=1` and `?aa` to find what makes a device slow. - **Sounds and vibration:** Kenney's sound effects (and a few made with ZzFX) for bombs, blasts, enemies, pickups, the gem, the exit, hits and a cleared level, timed with the animations; buttons click. Phones that can (Android) vibrate for blasts, hits and pickups. Both can be switched off in the menu. The demo and the clips stay silent. - **Coins for every cleared level** (2, and 5 more within par), coins from enemies more often (about 1 a kill), and **cheaper shop prices** (Bomb +1 4, most abilities 6, Heart 12, charges 3–6, a restock 1): the first shop now buys two to four things. Version 27: runs saved before don't resume. diff --git a/PLAN.md b/PLAN.md index daea85b..f32bb1b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -366,7 +366,7 @@ are in git history and decisions.md.) | Shop after level | Kills | From kills | Clear bonus | Within par | Total | |---|---|---|---|---|---| | 3 | 6 Blobs | ≈ 5 | 6 | + 15 | ≈ 11 – 26 | - | 6 | 8 Blobs | ≈ 7 | 6 | + 15 | ≈ 13 – 28 | + | 6 | 8, Blobs and Seekers | ≈ 7 | 6 | + 15 | ≈ 13 – 28 | | 9 | 10, Blobs and Seekers | ≈ 10 | 6 | + 15 | ≈ 16 – 31 | | 12 – 18 | 12 – 15 | ≈ 12 – 15 | 6 | + 15 | ≈ 18 – 36 | @@ -423,6 +423,21 @@ contact. Intent shown every turn. Adapted from the NES eight: | Hunter | Pass | 1 tile/turn | Always BFS shortest path to player | coins + charge | | Reaper | Pontan | 2 tiles/turn | Passes soft blocks, always chases. **Later milestone:** spawns only as punishment (turn limit, destroyed exit/pickup) | big coins | +- *Built 2026-10-24 (user request: "add more enemies"):* Sprinter, Ghost, Wisp and Hunter, as in + the table, with the sprites of §13. Choices made while building: + - **Speed as rests:** a plan still holds one move per turn; a Ghost's or Sprinter's every other + move is a rest (its own tile, drawn as a dot, eyes closed). A Sprinter's move is a dash of 2 + tiles in a line (two arrows), or 1 when the second is blocked. A dash through the player's + tile stops there and catches them; a bomb on either tile stops it. (`RESTS`, `moveTiles`.) + - **Through crates:** Ghosts and Wisps may enter soft blocks (`PASSES_CRATES`), drawn + see-through inside one; bombs and walls still stop them. Flame that burns their crate kills + them; a crate in front shelters them, as it stops the flame. + - **Hunter** always chases (unless another enemy takes that tile, as a Seeker's chase). + - **When they come:** one new type every 3 levels (Blob 1, Seeker 4, Drifter 7, Sprinter 10, + Ghost 13, Wisp 16, Hunter 19), so a 20-level run meets them all; it was every 6 levels while + there were three. Each enemy's type is picked evenly from those met so far. + - **Drops:** Sprinter and Wisp 40% coins / 60% charge, Ghost 90% / 25%, Hunter 90% / 60%. + `SIM_VERSION` 28. - 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 @@ -677,7 +692,7 @@ shows the scene's moment; the shortest of those is the scene (a new room if none Kinds (`SCENES` in `sim/demo.ts`, each with its moment): *since 2026-10-23 (user request)* every feature on the board but the shop and the exit — kill, **arrows** (an enemy killed on the tile it stepped onto, the bomb waiting where its arrows led: watch them; user request), escape, chain, -lingering flame, the three enemy types, drops, the gem (uncovered and taken; its choice isn't +lingering flame, the first three enemy types, drops, the gem (uncovered and taken; its choice isn't shown), each power-up that shows on the board (Bomb +1, Fire +1, Fuse +1, Run, Kick, Throw, Teleport, Diagonal, the Detonator, Wall / Bomb / Flame Pass, Star), and each charge (Pierce, Power, Nuke, Shield, Freeze, Decoy, picked up first). Heart and Coin bag only change numbers, diff --git a/README.md b/README.md index 3f4ed37..740ac7e 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,12 @@ 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, bombs, lives, the first three enemy types, coins, all +Status: **early development.** The board, bombs, lives, seven enemy types, coins, all seven charges that work when picked up, a hidden gem on every level that offers a choice of power-ups, the shop every 3rd level, the exit, the 20-level run with its ending, sounds and vibration, and saving -mid-run work. Curses and more enemies are planned — +mid-run work. Curses and the Reaper enemy are planned — see [PLAN.md](PLAN.md). -Version: **27**, shown at the bottom of the menu with the commit the game was built from (a link +Version: **28**, shown at the bottom of the menu with the commit the game was built from (a link to it on GitLab, plus "plus local changes" in a build with uncommitted changes) and a link to the [source code](https://gitlab.com/PavelSafronov/explodorama). The version is the rules version (`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)): it goes up by one whenever a rule @@ -43,7 +43,7 @@ faint static, a dark bar rolling down the screen and bright lines sweeping acros then a burst of interference, as if someone switched a vacuum cleaner on nearby, rounded glass in a dark bezel, at most 4:3 wide with black bars beside it on a wide screen — and the finger in front of the glass. The rest show every feature on the board: bombs and their countdown, watching the enemies' arrows to bomb where they're going, a -narrow escape, chain reactions, lingering flame, the three enemy types, drops, the gem, every +narrow escape, chain reactions, lingering flame, the first three enemy types, drops, the gem, every power-up you can see in action (Bomb +1, Fire +1, Fuse +1, Run, Kick — a tap on a bomb next to you —, Throw, Teleport, Diagonal, Slide, RPG, the Detonator — a tap on your bomb —, Proximity alert, Wall / Bomb / Flame Pass, @@ -201,13 +201,16 @@ will end up, a faded bomb with its blast, a kicked bomb's stopping place, a chan ### 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"> +<img src="docs/screenshots/enemies.png" alt="Level 19: three Ghosts (green blocks, one inside a crate, see-through), resting ones with a dot among their arrows, and a Wisp (red flame slime)" width="260" align="right"> Every enemy shows its **next 3 moves** as a column of see-through red arrows on the right side of its tile: the top arrow is its next move (strongest), the ones below are the 2nd and 3rd moves (fainter). It makes exactly those moves, one per turn, so you can time a bomb for where it will be. No arrows (and closed -eyes): it has nowhere to go and stays put. If you put a bomb on a tile it planned to walk to, +eyes): it has nowhere to go and stays put. A **dot** among the arrows is a rest: Ghosts and +Sprinters move only every other turn, with their eyes closed while they rest. **Two arrows one +behind the other** are a Sprinter's dash: 2 tiles in one turn (a dash through your tile catches +you there). If you put a bomb on a tile it planned to walk to, it stops there and plans a new route. Enemies keep clear of each other: each plans its moves around where the others will be, so a group (like the 3 let out of a blasted exit) splits up instead of moving as one; they only share a tile when there's nowhere else to go. Touching an @@ -217,8 +220,16 @@ 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 (or another enemy is heading there), then turns. | +| **Seeker** | fly | level 4 | Half the time takes the shortest way toward you; otherwise wanders like a Blob. | +| **Drifter** | spinning saw | level 7 | Goes straight until blocked (or another enemy is heading there), then turns. | +| **Sprinter** | mouse | level 10 | Rests every other turn, then dashes 2 tiles at once, half the time toward you. | +| **Ghost** | green block slime | level 13 | Moves every other turn, always the shortest way toward you, **through crates**. | +| **Wisp** | fire slime | level 16 | Wanders like a Blob, **through crates**. | +| **Hunter** | frog | level 19 | Always takes the shortest way toward you. | + +Ghosts and Wisps can be inside a crate (drawn see-through there); flame that burns that crate +kills them, but flame stopped by a crate in front of them doesn't reach them. Bombs stop every +enemy. Each level mixes the types met so far. Level 1 has 2 enemies, with one more every 4 levels (at most 5). Enemies never start within 4 tiles of you. @@ -365,7 +376,7 @@ Turns make sounds, timed with what you see: a pop when a bomb is set down, a boo picked up, the gem, the exit opening, a hurt, a cleared level; buttons click. Phones that can vibrate (Android; iPhones can't, from a web page) also buzz for blasts, hits, pickups and a cleared level. Both can be turned off in the menu, and the setting is remembered in this browser. -The demo and the clips in the dialogs are silent. On an iPhone, the silent switch mutes the game. +The demo and the clips in the dialogs are silent. On an iPhone, the silent switch mutes the game. A phone in **Mute** mode (on Android, the sound mode in the quick settings) doesn't vibrate either: switch it to Vibrate or Sound. ### The board @@ -387,8 +398,8 @@ npm run preview # serve dist/ (also on the network, with a QR code), as it 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 (level 13 -has all three enemy types); `?charges=2` starts as if 2 of each lasting charge had been picked up +URL parameters: `?seed=123` plays a fixed board; `?level=4` starts at a later level (level 19 +has all seven enemy types); `?charges=2` starts as if 2 of each lasting charge had been picked up (6 Pierce, Power and Nuke bombs, and a Shield), for trying them out; `?dev` and `?immortal` set the modes; `?demo` shows the demo, which test URLs otherwise skip (with `?seed`, the same demo every time). `?fps` shows a frame-rate meter in the bottom left corner: frames a second, the slowest frame, the game's own time per frame, and the canvas size. For tracking down slowness on a device: `?gl=0` draws with Canvas 2D instead of WebGL (no demo TV effect, no clips), `?ratio=1` draws at one pixel per CSS pixel, `?aa` turns WebGL's antialiasing on (it is off: it held Android Chrome to 12 fps). These four keep your saved run. A URL with any of these parameters plays a test board: it doesn't load or overwrite your saved run. diff --git a/decisions.md b/decisions.md index 55d71a2..493901a 100644 --- a/decisions.md +++ b/decisions.md @@ -1791,3 +1791,26 @@ frame. (Headless Chrome warned "GPU stall due to ReadPixels" too.) manages at 60 fps once antialiasing is off; the cost was one setting, not the hardware's limits. Such tuning (e.g. dropping the pixel ratio after a few slow seconds) is to add only if a slow device turns up, measured first with `?fps` and the switches. + +### 2026-10-24 — Sprinter, Ghost, Wisp and Hunter + +User request: "can you add more enemies next?" Built the four of PLAN.md §10 (the Reaper stays for +later), with the sprites §13 had chosen (mouse, block slime, fire slime, frog). The table's speeds +had to fit plans of one move per turn, which the arrows, the danger overlay and Proximity alert +all read: + +- **Every other turn is a rest**, a planned move to the tile it is on (a dot among the arrows; + eyes closed). Plans keep alternating as they are topped up; with nothing kept, the turn's + evenness and the enemy's id decide, so a group doesn't all move on the same turns. A Freeze + holds the plan as it is, rests included. +- **A Sprinter's move is a dash of 2 tiles** in a line (drawn as two arrows), 1 if the second + tile is blocked. The middle tile counts: a bomb there stops the dash, and the player there is + caught (the Sprinter stops on them). Auto-move's safety check doesn't step onto either tile. +- **Ghosts and Wisps go through crates**, as the NES's Kondoria and Ovapi went through soft + blocks; drawn see-through inside one. Flame stops on a crate and burns it, so it kills one + inside the first crate it reaches but not one behind it. +- **A new type every 3 levels**, not 6, so a 20-level run meets all seven by level 19. This moves + the Seeker to level 4 and the Drifter to 7. +- **Drops** follow the table's words: Sprinter and Wisp mostly a charge, Ghost coins, Hunter both. + +`SIM_VERSION` 28. Tests in `test/new-enemies.test.ts`. diff --git a/docs/screenshots/almanac-enemies.png b/docs/screenshots/almanac-enemies.png index fbab680..b6f6744 100644 Binary files a/docs/screenshots/almanac-enemies.png and b/docs/screenshots/almanac-enemies.png differ diff --git a/docs/screenshots/enemies.png b/docs/screenshots/enemies.png index 6c979a6..b8e6958 100644 Binary files a/docs/screenshots/enemies.png and b/docs/screenshots/enemies.png differ diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png index b996e3f..b7866bd 100644 Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ diff --git a/src/render/board.ts b/src/render/board.ts index 61c20d1..9002b59 100644 --- a/src/render/board.ts +++ b/src/render/board.ts @@ -398,17 +398,21 @@ function drawEnemy(s: GameState, e: Enemy, stepped: { from: Vec; at: number } | const pos = k < 1 ? toWorld(s, stepped!.from).lerp(toWorld(s, e.pos), k).add(vec2(0, 0.08 * Math.sin(k * Math.PI))) : toWorld(s, e.pos) const art = ENEMY_SPRITES[e.kind] const moving = e.plan.length > 0 + // Resting this turn (a Ghost or a Sprinter between moves): eyes closed, like one with nowhere to go. + const resting = moving && eq(e.plan[0]!, e.pos) && k >= 1 // Frozen (Freeze): still, blue and iced over, thawing a step each turn. const frost = frostLevel(fx, s, time) const still = frost > 0.05 - const frame = !moving ? art.rest : still ? art.a : Math.floor(time * 2.5 + e.id) % 2 ? art.a : art.b + const frame = !moving || resting ? art.rest : still ? art.a : Math.floor(time * 2.5 + e.id) % 2 ? art.a : art.b const angle = art.spins && moving && !still ? time * 3 : 0 - const heading = moving ? dirBetween(e.pos, e.plan[0]!) : e.dir + const heading = (moving && e.plan.map((to, i) => dirBetween(i ? e.plan[i - 1]! : e.pos, to)).find((d) => d)) || e.dir const mirror = art.facesLeft && heading === 'right' // Just spawned: pops in. const size = 0.8 * appearScale(fx, key.enemy(e.id), time) const at = pos.add(vec2(-0.09 + frostCrack(fx, time), 0)) - drawTile(at, vec2(size), sprite(frame), C.white.lerp(C.ice, frost), angle, mirror, C.frostGlow.scale(frost, 1)) + // Inside a crate (a Ghost or a Wisp): see-through, so the crate shows it is in there. + const inCrate = cellAt(s, e.pos) === 'block' + drawTile(at, vec2(size), sprite(frame), C.white.lerp(C.ice, frost).scale(1, inCrate ? 0.6 : 1), angle, mirror, C.frostGlow.scale(frost, 1)) if (frost > 0) drawIce(at, size, frost, e.id) if (!moving) return @@ -416,10 +420,15 @@ function drawEnemy(s: GameState, e: Enemy, stepped: { from: Vec; at: number } | const top = pos.add(vec2(0.35, gap)) let from = e.pos for (const [i, to] of e.plan.entries()) { - const d = vec2(to.x - from.x, from.y - to.y) // world y is up + const d = vec2(Math.sign(to.x - from.x), Math.sign(from.y - to.y)) // world y is up + const tiles = Math.abs(to.x - from.x) + Math.abs(to.y - from.y) // Frozen, its plan waits: the arrows go icy and faint until it thaws. const color = C.intent.lerp(C.ice, frost).scale(1, (PLAN_ALPHA[i] ?? 0.3) * (1 - 0.5 * frost)) - drawArrow(top.add(vec2(0, -gap * i)), d, 0.26, color) + const slot = top.add(vec2(0, -gap * i)) + // A rest is a dot; a Sprinter's 2-tile dash, two arrows one behind the other. + if (!tiles) drawCircle(slot, 0.13, color) + else if (tiles > 1) for (const a of [-0.07, 0.07]) drawArrow(slot.add(d.scale(a)), d, 0.2, color) + else drawArrow(slot, d, 0.26, color) from = to } } diff --git a/src/render/sprites.ts b/src/render/sprites.ts index d26cd5b..46b8520 100644 --- a/src/render/sprites.ts +++ b/src/render/sprites.ts @@ -77,6 +77,18 @@ const BOARD = [ 'saw_rest', 'saw_a', 'saw_b', + 'mouse_rest', + 'mouse_walk_a', + 'mouse_walk_b', + 'slime_block_rest', + 'slime_block_walk_a', + 'slime_block_walk_b', + 'slime_fire_rest', + 'slime_fire_walk_a', + 'slime_fire_walk_b', + 'frog_rest', + 'frog_idle', + 'frog_jump', 'powerup_bomb_plus', 'powerup_fire_plus', 'powerup_fuse_plus', @@ -178,6 +190,11 @@ export const ENEMY_SPRITES: Record<EnemyKind, { rest: BoardSprite; a: BoardSprit 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 }, + // The later ones (2026-10-24), with the sprites PLAN.md §13 chose for them. + sprinter: { rest: 'mouse_rest', a: 'mouse_walk_a', b: 'mouse_walk_b', facesLeft: true, spins: false }, + ghost: { rest: 'slime_block_rest', a: 'slime_block_walk_a', b: 'slime_block_walk_b', facesLeft: true, spins: false }, + wisp: { rest: 'slime_fire_rest', a: 'slime_fire_walk_a', b: 'slime_fire_walk_b', facesLeft: true, spins: false }, + hunter: { rest: 'frog_rest', a: 'frog_idle', b: 'frog_jump', facesLeft: true, spins: false }, } /** Coin drops look like their value: bronze 1, silver 2, gold 5 or more. */ diff --git a/src/sim/demo.ts b/src/sim/demo.ts index c55cee7..151dc54 100644 --- a/src/sim/demo.ts +++ b/src/sim/demo.ts @@ -226,7 +226,7 @@ export const SCENES: Record<SceneKind, SceneDef> = { shows: (t) => kills(t).some((k) => t.prev.burning.includes(idx(t.prev, k.pos))), }, enemies: { - caption: 'Three kinds of enemy: Blobs wander, Seekers chase you, Drifters go straight until blocked.', + caption: 'Each kind of enemy moves its own way: Blobs wander, Seekers chase you, Drifters go straight. More kinds come later.', enemies: ['blob', 'seeker', 'drifter'], shows: (t) => kills(t).length > 0, }, diff --git a/src/sim/enemies.ts b/src/sim/enemies.ts index c8fa0c1..8cc2e86 100644 --- a/src/sim/enemies.ts +++ b/src/sim/enemies.ts @@ -2,9 +2,22 @@ import { Rng } from './rng' import { add, bombAt, cellAt, eq } from './state' import { CHARGE_IDS, DIRS, DIR_LIST, type ChargeId, type Dir, type Drop, type Enemy, type EnemyKind, type GameEvent, type GameState, type Vec } from './types' -/** Enemies walk on floor and are stopped by bombs. They don't block each other; they can share a tile. */ -export function enemyCanEnter(s: GameState, p: Vec): boolean { - return cellAt(s, p) === 'floor' && !bombAt(s, p) +/** Ghosts and Wisps go through crates (soft blocks), as the NES's Kondoria and Ovapi did. */ +export const PASSES_CRATES: ReadonlySet<EnemyKind> = new Set(['ghost', 'wisp']) + +/** + * Ghosts and Sprinters move only every other turn, resting in between (a Sprinter dashes 2 tiles + * when it moves). A rest is a planned "move" to the tile it is on. + */ +export const RESTS: ReadonlySet<EnemyKind> = new Set(['ghost', 'sprinter']) + +/** + * Enemies walk on floor, Ghosts and Wisps through crates too, and all are stopped by bombs. They + * don't block each other; they can share a tile. + */ +export function enemyCanEnter(s: GameState, p: Vec, kind?: EnemyKind): boolean { + const cell = cellAt(s, p) + return (cell === 'floor' || (cell === 'block' && !!kind && PASSES_CRATES.has(kind))) && !bombAt(s, p) } export function enemyAt(s: GameState, p: Vec): Enemy | undefined { @@ -18,22 +31,45 @@ interface Step { /** Where an enemy is, or will be, while its moves are planned ahead. */ interface Walker { + kind: EnemyKind pos: Vec dir: Dir /** Whether another enemy, planned before this one, will be on `p` at the move being planned. */ crowded(p: Vec): boolean } -/** The direction of a one-tile step. */ -export function dirBetween(from: Vec, to: Vec): Dir { - return DIR_LIST.find((d) => eq(add(from, DIRS[d]), to))! +/** The direction of a step in a straight line (one tile, or a Sprinter's two); null for a rest. */ +export function dirBetween(from: Vec, to: Vec): Dir | null { + const dx = Math.sign(to.x - from.x) + const dy = Math.sign(to.y - from.y) + return DIR_LIST.find((d) => DIRS[d].x === dx && DIRS[d].y === dy) ?? null +} + +/** + * The tiles a planned move from `from` to `to` passes over and ends on: none for a rest, the one + * tile for a step, the middle one and the end for a Sprinter's dash. + */ +export function moveTiles(from: Vec, to: Vec): Vec[] { + const steps = Math.abs(to.x - from.x) + Math.abs(to.y - from.y) + if (steps < 2) return steps ? [to] : [] + return [{ x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }, to] +} + +/** Whether a kept planned move is still one this enemy can make on the board as it is now. */ +function stillPossible(s: GameState, kind: EnemyKind, from: Vec, to: Vec): boolean { + const dx = Math.abs(to.x - from.x) + const dy = Math.abs(to.y - from.y) + if (dx + dy === 0) return RESTS.has(kind) + if (dx + dy === 2 && (dx === 0 || dy === 0) && kind !== 'sprinter') return false + if (dx + dy > 2 || (dx && dy)) return false + return moveTiles(from, to).every((p) => enemyCanEnter(s, p, kind)) } /** How many moves ahead every enemy plans, and shows (PLAN.md §4). */ export const PLAN_AHEAD = 3 -function openSteps(s: GameState, w: Pick<Walker, 'pos'>): Step[] { - return DIR_LIST.map((dir) => ({ dir, to: add(w.pos, DIRS[dir]) })).filter((st) => enemyCanEnter(s, st.to)) +function openSteps(s: GameState, w: Pick<Walker, 'pos' | 'kind'>): Step[] { + return DIR_LIST.map((dir) => ({ dir, to: add(w.pos, DIRS[dir]) })).filter((st) => enemyCanEnter(s, st.to, w.kind)) } /** @@ -61,10 +97,11 @@ function drift(s: GameState, w: Walker, rng: Rng): Step | null { } /** - * The first step of a shortest path over tiles enemies can enter toward what they chase: the - * decoy while there is one, otherwise the player. Null if there is no way. + * The first step of a shortest path over tiles this enemy can enter (through crates, for a + * Ghost) toward what they chase: the decoy while there is one, otherwise the player. Null if + * there is no way. */ -export function stepToward(s: GameState, w: Pick<Walker, 'pos'>): Step | null { +export function stepToward(s: GameState, w: Pick<Walker, 'pos' | 'kind'>): Step | null { const goal = s.decoy?.pos ?? s.player const key = (p: Vec) => p.y * s.width + p.x const first = new Map<number, Step>() @@ -78,7 +115,7 @@ export function stepToward(s: GameState, w: Pick<Walker, 'pos'>): Step | null { const p = queue[i]! for (const d of DIR_LIST) { const q = add(p, DIRS[d]) - if (first.has(key(q)) || eq(q, w.pos) || !enemyCanEnter(s, q)) continue + if (first.has(key(q)) || eq(q, w.pos) || !enemyCanEnter(s, q, w.kind)) continue first.set(key(q), first.get(key(p))!) if (eq(q, goal)) return first.get(key(q))! queue.push(q) @@ -87,15 +124,35 @@ export function stepToward(s: GameState, w: Pick<Walker, 'pos'>): Step | null { return null } +/** A step along the shortest way to the player (unless another enemy takes that tile then), else a wander. */ +function chase(s: GameState, w: Walker, rng: Rng): Step | null { + const toward = stepToward(s, w) + return toward && !w.crowded(toward.to) ? toward : wander(s, w, rng) +} + +/** + * Sprinter: half the time a chase, otherwise a wander; then on along the same line for a second + * tile if it can (a dash), or just the one. + */ +function dash(s: GameState, w: Walker, rng: Rng): Step | null { + const first = rng.next() < 0.5 ? chase(s, w, rng) : wander(s, w, rng) + if (!first) return null + const second = add(first.to, DIRS[first.dir]) + return enemyCanEnter(s, second, w.kind) && !w.crowded(second) ? { dir: first.dir, to: second } : first +} + const PLANNERS: Record<EnemyKind, (s: GameState, w: Walker, rng: Rng) => Step | null> = { blob: wander, - // Seeker: half the time a step along the shortest path to the player (unless another enemy - // takes that tile then), otherwise a Blob's wander. - seeker: (s, w, rng) => { - const toward = rng.next() < 0.5 ? stepToward(s, w) : null - return toward && !w.crowded(toward.to) ? toward : wander(s, w, rng) - }, + // Seeker: half the time a chase, otherwise a Blob's wander. + seeker: (s, w, rng) => (rng.next() < 0.5 ? chase(s, w, rng) : wander(s, w, rng)), drifter: drift, + sprinter: dash, + // Ghost: always chases, through crates, every other turn. + ghost: chase, + // Wisp: wanders like a Blob, through crates. + wisp: wander, + // Hunter: always chases. + hunter: chase, } /** @@ -114,25 +171,33 @@ export function planIntents(s: GameState): void { for (const e of s.enemies) { let from = e.pos const kept: Vec[] = [] + let dir = e.dir + let rested: boolean | null = null for (const p of e.plan) { - if (!enemyCanEnter(s, p) || Math.abs(p.x - from.x) + Math.abs(p.y - from.y) !== 1) break + if (!stillPossible(s, e.kind, from, p)) break kept.push(p) + rested = eq(p, from) + dir = dirBetween(from, p) ?? dir from = p } - // Carry on from the last kept step, heading the way that step went. - const last = kept.at(-1) - const w: Walker = { - pos: from, - dir: last ? dirBetween(kept.at(-2) ?? e.pos, last) : e.dir, - crowded: (p) => taken.has(at(e.plan.length + 1, p)), - } + // Carry on from the last kept step, heading the way the last move went. + const w: Walker = { kind: e.kind, pos: from, dir, crowded: (p) => taken.has(at(e.plan.length + 1, p)) } + // One that rests moves and rests by turns: after its last kept move it rests; with nothing + // kept, the turn's evenness decides, so they don't all move on the same turns. + let rest = RESTS.has(e.kind) && (rested === null ? (s.turn + e.id) % 2 === 1 : !rested) e.plan = kept while (e.plan.length < PLAN_AHEAD) { + if (rest) { + e.plan.push(w.pos) + rest = false + continue + } const st = PLANNERS[e.kind](s, w, rng) if (!st) break e.plan.push(st.to) w.pos = st.to w.dir = st.dir + rest = RESTS.has(e.kind) } e.dir = w.dir for (let t = 1; t <= PLAN_AHEAD; t++) taken.add(at(t, e.plan[t - 1] ?? e.plan.at(-1) ?? e.pos)) @@ -153,13 +218,19 @@ export function moveEnemies(s: GameState, events: GameEvent[]): void { for (const e of s.enemies) { const next = e.plan[0] if (!next) continue - if (!enemyCanEnter(s, next)) { + const tiles = moveTiles(e.pos, next) + if (!tiles.every((p) => enemyCanEnter(s, p, e.kind))) { e.plan = [] continue } - events.push({ type: 'enemyMoved', id: e.id, from: e.pos, to: next }) - e.pos = next e.plan.shift() + // A rest: it stays where it is. + if (!tiles.length) continue + // A Sprinter dashing through the player's tile stops there: it caught them. + const to = tiles.find((p) => eq(p, s.player)) ?? next + if (!eq(to, next)) e.plan = [] + events.push({ type: 'enemyMoved', id: e.id, from: e.pos, to }) + e.pos = to } } @@ -171,6 +242,12 @@ const DROP_CHANCE: Record<EnemyKind, { coins: number; charge: number }> = { blob: { coins: 0.6, charge: 0.15 }, seeker: { coins: 0.8, charge: 0.25 }, drifter: { coins: 0.5, charge: 0.5 }, + // The later ones (2026-10-24), after PLAN.md §10: a Sprinter and a Wisp mostly a charge, a + // Ghost coins, a Hunter both. + sprinter: { coins: 0.4, charge: 0.6 }, + ghost: { coins: 0.9, charge: 0.25 }, + wisp: { coins: 0.4, charge: 0.6 }, + hunter: { coins: 0.9, charge: 0.6 }, } /** diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts index 4fe4b9a..13b59a9 100644 --- a/src/sim/levelgen.ts +++ b/src/sim/levelgen.ts @@ -17,12 +17,14 @@ export const SAFE_DISTANCE = 4 export const LAST_LEVEL = 20 /** - * 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. + * Which enemy types a level can have (PLAN.md §10): Blobs only at first, then one new type every + * 3 levels, so all seven are met by level 19 of the 20 (every 6 levels while there were three). */ +export const ENEMY_ORDER: readonly EnemyKind[] = ['blob', 'seeker', 'drifter', 'sprinter', 'ghost', 'wisp', 'hunter'] +export const NEW_ENEMY_EVERY = 3 + export function enemyKinds(level: number): EnemyKind[] { - const all: EnemyKind[] = ['blob', 'seeker', 'drifter'] - return all.slice(0, 1 + Math.floor((level - 1) / 6)) + return ENEMY_ORDER.slice(0, 1 + Math.floor((level - 1) / NEW_ENEMY_EVERY)) } /** 2 enemies on level 1, one more every 4 levels, at most 5. To tune by playtesting. */ diff --git a/src/sim/path.ts b/src/sim/path.ts index cc6433d..559e12e 100644 --- a/src/sim/path.ts +++ b/src/sim/path.ts @@ -1,6 +1,6 @@ import { ACTIONS, isAvailable, isValidTarget } from './actions' import type { Danger } from './danger' -import { enemyAt } from './enemies' +import { enemyAt, moveTiles } from './enemies' import { add, eq, exitOpen, flameSafe, idx, playerCanPass } from './state' import { DIRS, DIR_LIST, type Action, type GameState, type Vec } from './types' @@ -71,11 +71,11 @@ export function autoMoveAction(s: GameState, to: Vec, safe: (p: Vec) => boolean) } /** - * Whether auto-move may take this step: not onto an enemy, not onto the tile an enemy moves to - * next, and not into flame this turn. Those would be deaths the player didn't choose. + * Whether auto-move may take this step: not onto an enemy, not onto a tile an enemy moves to or + * dashes through next, and not into flame this turn. Those would be deaths the player didn't choose. */ export function safeStep(s: GameState, danger: Danger, p: Vec): boolean { // Frozen enemies won't move this turn; flame doesn't matter while it can't hurt. - const intended = s.frozen === 0 && s.enemies.some((e) => e.plan[0] && eq(e.plan[0], p)) + const intended = s.frozen === 0 && s.enemies.some((e) => e.plan[0] && moveTiles(e.pos, e.plan[0]).some((t) => eq(t, p))) return !enemyAt(s, p) && !intended && (flameSafe(s) || !danger.now.has(idx(s, p))) } diff --git a/src/sim/record.ts b/src/sim/record.ts index dd49645..12b6210 100644 --- a/src/sim/record.ts +++ b/src/sim/record.ts @@ -6,7 +6,7 @@ import { DEFAULT_OPTIONS, type Action, type GameEvent, type GameState, type RunO * Bumped whenever a rule change would make an old record replay differently. Records from * another version can't be trusted to replay (PLAN.md §11). */ -export const SIM_VERSION = 27 +export const SIM_VERSION = 28 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */ export interface RunRecord { diff --git a/src/sim/types.ts b/src/sim/types.ts index 5533ebb..8b228ae 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -133,8 +133,8 @@ export interface Powers { slide: boolean } -/** The enemy types so far (PLAN.md §10). */ -export type EnemyKind = 'blob' | 'seeker' | 'drifter' +/** The enemy types so far (PLAN.md §10); the Reaper is for later. */ +export type EnemyKind = 'blob' | 'seeker' | 'drifter' | 'sprinter' | 'ghost' | 'wisp' | 'hunter' export interface Enemy { /** Stable for the enemy's life, so the renderer can follow it between turns. */ @@ -144,8 +144,10 @@ export interface Enemy { /** The way its last planned move goes. Blobs and Drifters keep going this way. */ dir: Dir /** - * Its next moves, up to PLAN_AHEAD tiles, first one first (announced in phase 5, taken one - * per turn in phase 2). Empty: it has nowhere to go and stays put. + * Its next moves, up to PLAN_AHEAD of them, first one first (announced in phase 5, taken one + * per turn in phase 2): where it will be after each. Usually the next tile; the tile it is on + * for a rest (Ghost, Sprinter); 2 tiles on for a Sprinter's dash. Empty: it has nowhere to go + * and stays put. */ plan: Vec[] /** Spawned as a punishment (the exit was flamed): drops nothing. */ diff --git a/src/ui/almanac.ts b/src/ui/almanac.ts index 51c5276..d97333c 100644 --- a/src/ui/almanac.ts +++ b/src/ui/almanac.ts @@ -1,4 +1,4 @@ -import { enemyKinds } from '../sim/levelgen' +import { ENEMY_ORDER, enemyKinds } from '../sim/levelgen' import { OFFER_SIZE } from '../sim/turn' import { CHARGE_IDS, type EnemyKind, type PowerUpKind } from '../sim/types' import { CHARGE_ICONS, ENEMY_SPRITES, POWER_UP_ICONS, spriteUrl } from '../render/sprites' @@ -25,7 +25,7 @@ const POWER_UPS: PowerUpKind[] = [ 'wallPass', 'bombPass', 'flamePass', 'star', 'heart', 'coinBag', ] -const ENEMIES: EnemyKind[] = ['blob', 'seeker', 'drifter'] +const ENEMIES = ENEMY_ORDER /** The first level an enemy type can appear on. */ function firstLevel(kind: EnemyKind): number { diff --git a/src/ui/info.ts b/src/ui/info.ts index 28478f1..3aaf144 100644 --- a/src/ui/info.ts +++ b/src/ui/info.ts @@ -4,7 +4,7 @@ import { bombAt, cellAt, eq, exitOpen, isBurning, CLEAR_BONUS, PAR_BONUS } from import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS } from '../sim/turn' import { SHOP_EVERY } from '../sim/shop' import type { ActionId, ChargeId, Death, Dir, EnemyKind, GameState, Powers, PowerUpKind, Vec } from '../sim/types' -import { dirBetween } from '../sim/enemies' +import { dirBetween, RESTS } from '../sim/enemies' import type { SceneKind } from '../sim/demo' import { CHARGE_ICONS, coinSprite, ENEMY_SPRITES, GEM_SPRITES, isOuterWall, POWER_UP_ICONS, spriteUrl, wallSprite } from '../render/sprites' @@ -26,6 +26,10 @@ export const ENEMY_TEXT: Record<EnemyKind, { name: string; moves: string }> = { blob: { name: 'Blob', moves: 'Wanders, usually carrying on the way it was going.' }, seeker: { name: 'Seeker', moves: 'Half the time it takes the shortest way toward you (or your decoy); otherwise it wanders like a Blob.' }, drifter: { name: 'Drifter', moves: 'Goes straight until something blocks it (or another enemy is heading there), then turns.' }, + sprinter: { name: 'Sprinter', moves: 'Rests every other turn, then dashes 2 tiles at once, half the time toward you (or your decoy). A dash through your tile catches you.' }, + ghost: { name: 'Ghost', moves: 'Moves every other turn, always the shortest way toward you (or your decoy), straight through crates. Flame that burns its crate kills it.' }, + wisp: { name: 'Wisp', moves: 'Wanders like a Blob, but straight through crates. Flame that burns its crate kills it.' }, + hunter: { name: 'Hunter', moves: 'Always takes the shortest way toward you (or your decoy).' }, } export const POWER_UP_TEXT: Record<PowerUpKind, { name: string; text: string }> = { @@ -216,7 +220,14 @@ export function tileInfo(s: GameState, p: Vec): Info | null { const enemies = s.enemies.filter((e) => eq(e.pos, p)) const e = enemies[0] if (e) { - const moves = e.plan.map((to, i) => ARROWS[dirBetween(i ? e.plan[i - 1]! : e.pos, to)]).join(' ') + const moves = e.plan + .map((to, i) => { + const from = i ? e.plan[i - 1]! : e.pos + const dir = dirBetween(from, to) + // A rest is a dot; a Sprinter's dash, a doubled arrow. + return !dir ? '·' : Math.abs(to.x - from.x) + Math.abs(to.y - from.y) > 1 ? ARROWS[dir].repeat(2) : ARROWS[dir] + }) + .join(' ') const more = enemies.length > 1 ? ` (and ${enemies.length - 1} more on this tile)` : '' return { icon: ENEMY_SPRITES[e.kind].a, @@ -224,7 +235,7 @@ export function tileInfo(s: GameState, p: Vec): Info | null { body: [ ENEMY_TEXT[e.kind].moves, moves - ? `Its next moves: ${moves}. The arrows beside it show them, next move on top. It makes exactly those moves unless a bomb gets in its way.` + ? `Its next moves: ${moves}. The arrows beside it show them, next move on top${RESTS.has(e.kind) ? ' (a dot is a rest' + (e.kind === 'sprinter' ? ', a doubled arrow a 2-tile dash)' : ')') : ''}. It makes exactly those moves unless a bomb gets in its way.` : 'It has nowhere to go right now, so it stays put (eyes closed).', 'Flame kills it, including flame still burning from last turn. Touching it costs you a life.', ], diff --git a/test/enemies.test.ts b/test/enemies.test.ts index 919f04d..d7d6b3e 100644 --- a/test/enemies.test.ts +++ b/test/enemies.test.ts @@ -27,12 +27,16 @@ describe('levelgen: enemies and exit', () => { }) } - it('brings in a new type every 6 levels', () => { + it('brings in a new type every 3 levels, all seven by level 19', () => { 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']) + expect(enemyKinds(3)).toEqual(['blob']) + expect(enemyKinds(4)).toEqual(['blob', 'seeker']) + expect(enemyKinds(7)).toEqual(['blob', 'seeker', 'drifter']) + expect(enemyKinds(10)).toContain('sprinter') + expect(enemyKinds(13)).toContain('ghost') + expect(enemyKinds(16)).toContain('wisp') + expect(enemyKinds(19)).toEqual(['blob', 'seeker', 'drifter', 'sprinter', 'ghost', 'wisp', 'hunter']) + expect(enemyKinds(50)).toEqual(enemyKinds(19)) }) it('a new level already shows every enemy plan, up to 3 moves', () => { diff --git a/test/helpers.ts b/test/helpers.ts index ec6ae89..3147eb0 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,13 +1,14 @@ import { noBoosts, noPowers, START_LIVES, START_STATS } from '../src/sim/state' import { DEFAULT_OPTIONS, type Cell, type EnemyKind, type GameState, type Stats, type Vec } from '../src/sim/types' -const ENEMIES: Record<string, EnemyKind> = { B: 'blob', S: 'seeker', D: 'drifter' } +const ENEMIES: Record<string, EnemyKind> = { B: 'blob', S: 'seeker', D: 'drifter', R: 'sprinter', G: 'ghost', W: 'wisp', H: 'hunter' } /** * A state from a picture: `#` wall, `+` soft block, `.` floor, `P` player (on floor), * `1`–`9` a bomb with that timer (on floor, placed on an earlier turn), - * `B` / `S` / `D` a Blob / Seeker / Drifter facing right with an empty plan (it stays put unless - * the test gives it one), `E` the exit uncovered, `e` the exit under a soft block, `$` a 1-coin drop. + * `B` / `S` / `D` / `R` / `G` / `W` / `H` a Blob / Seeker / Drifter / Sprinter (runner) / Ghost / + * Wisp / Hunter facing right with an empty plan (it stays put unless the test gives it one), + * `E` the exit uncovered, `e` the exit under a soft block, `$` a 1-coin drop. * Without `E` or `e` the exit is off the board, so it never opens. */ export function board(picture: string, stats: Partial<Stats> = {}): GameState { diff --git a/test/new-enemies.test.ts b/test/new-enemies.test.ts new file mode 100644 index 0000000..3cbcd59 --- /dev/null +++ b/test/new-enemies.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest' +import { predictDanger } from '../src/sim/danger' +import { enemyCanEnter, moveTiles, planIntents } from '../src/sim/enemies' +import { safeStep } from '../src/sim/path' +import { step } from '../src/sim/turn' +import type { GameState, Vec } from '../src/sim/types' +import { board, v } from './helpers' + +const steps = (a: Vec, b: Vec) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y) + +/** The size of each planned move: 0 a rest, 1 a step, 2 a Sprinter's dash. */ +function moveSizes(s: GameState, i = 0): number[] { + const e = s.enemies[i]! + return e.plan.map((to, k) => steps(k ? e.plan[k - 1]! : e.pos, to)) +} + +describe('Ghosts and Wisps go through crates', () => { + it('a Ghost or a Wisp can enter a crate; a Blob, Hunter or Sprinter cannot; a bomb stops all', () => { + const s = board(` + ##### + #P+1# + #####`) + for (const kind of ['ghost', 'wisp'] as const) expect(enemyCanEnter(s, v(2, 1), kind)).toBe(true) + for (const kind of ['blob', 'hunter', 'sprinter'] as const) expect(enemyCanEnter(s, v(2, 1), kind)).toBe(false) + expect(enemyCanEnter(s, v(3, 1), 'ghost')).toBe(false) + }) + + it('a Ghost chases straight through a wall of crates', () => { + const s = board(` + ####### + #P++.G# + #######`) + let s2 = s + planIntents(s2) + const been: Vec[] = [] + for (let turn = 0; turn < 5; turn++) { + s2 = step(s2, { id: 'wait' }).state + been.push(s2.enemies[0]!.pos) + } + // Into the first crate, and on to the next, a step every other turn. + expect(been).toContainEqual(v(3, 1)) + expect(been.filter((p) => p.x === 3)).toHaveLength(2) + }) + + it('flame that burns the crate a Ghost is in kills it', () => { + let s = board(` + ###### + #P.1+# + ######`) + s.enemies.push({ id: 0, kind: 'ghost', pos: v(4, 1), dir: 'left', plan: [v(4, 1)], spawned: true, sheltered: false }) + s.bombs[0]!.timer = 1 + s = step(s, { id: 'wait' }).state + expect(s.enemies).toHaveLength(0) + }) +}) + +describe('Ghosts and Sprinters rest every other turn', () => { + it('a Ghost alternates a rest and a step', () => { + const s = board(` + ######### + #P.....G# + #########`) + planIntents(s) + const sizes = moveSizes(s) + expect(sizes).toHaveLength(3) + expect(sizes.every((n, k) => n === (sizes[0]! === 0 ? [0, 1, 0] : [1, 0, 1])[k])).toBe(true) + }) + + it('keeps alternating as its plan is topped up turn after turn', () => { + let s = board(` + ########### + #P.......G# + ###########`) + planIntents(s) + const done: number[] = [] + for (let turn = 0; turn < 6; turn++) { + const before = s.enemies[0]!.pos + s = step(s, { id: 'wait' }).state + done.push(steps(before, s.enemies[0]!.pos)) + } + for (let k = 1; k < done.length; k++) expect(done[k]).not.toBe(done[k - 1]) + }) + + it('a Sprinter dashes 2 tiles along a corridor, resting between dashes', () => { + const s = board(` + ########### + #P.......R# + ###########`) + planIntents(s) + const sizes = moveSizes(s).filter((n) => n > 0) + expect(sizes.length).toBeGreaterThan(0) + expect(sizes.every((n) => n === 2)).toBe(true) + }) + + it('a dash goes only 1 tile when the second is blocked', () => { + const s = board(` + ##### + #.R.# + #P### + #####`) + s.enemies[0]!.plan = [v(2, 1)] + planIntents(s) + for (const [k, to] of s.enemies[0]!.plan.entries()) expect(steps(k ? s.enemies[0]!.plan[k - 1]! : v(2, 1), to)).toBeLessThanOrEqual(1) + }) + + it('a dash through the player catches them on the way', () => { + const s = board(` + ###### + #R.P.# + ######`) + s.enemies[0]!.plan = [v(3, 1)] + const r = step(s, { id: 'wait' }) + // The dash would have ended at (3, 1) anyway here; a longer one stops on the player. + expect(r.state.status).toBe('dead') + }) + + it('a dash whose middle tile is the player stops there', () => { + const s = board(` + ####### + #.R.P.# + #.##### + #######`) + s.player = v(3, 1) + s.enemies[0]!.plan = [v(4, 1)] + const r = step(s, { id: 'wait' }) + expect(r.events).toContainEqual({ type: 'enemyMoved', id: 0, from: v(2, 1), to: v(3, 1) }) + expect(r.state.status).toBe('dead') + }) + + it('a bomb on the middle tile stops a dash', () => { + const s = board(` + ####### + #R5..P# + #######`) + s.enemies[0]!.plan = [v(3, 1)] + const r = step(s, { id: 'wait' }) + expect(r.state.enemies[0]!.pos).toEqual(v(1, 1)) + }) + + it('moveTiles: none for a rest, the tile for a step, the middle and the end for a dash', () => { + expect(moveTiles(v(1, 1), v(1, 1))).toEqual([]) + expect(moveTiles(v(1, 1), v(2, 1))).toEqual([v(2, 1)]) + expect(moveTiles(v(1, 1), v(1, 3))).toEqual([v(1, 2), v(1, 3)]) + }) +}) + +describe('Hunters always chase', () => { + it('every planned step of a Hunter gets closer to the player', () => { + for (let seed = 1; seed <= 20; seed++) { + const s = board(` + ######### + #P......# + #.#.#.#.# + #......H# + #########`) + s.aiRng = seed + planIntents(s) + let from = s.enemies[0]!.pos + for (const to of s.enemies[0]!.plan) { + expect(steps(to, s.player)).toBe(steps(from, s.player) - 1) + from = to + } + } + }) +}) + +describe('auto-move and a dash', () => { + it('will not step onto the middle tile of a Sprinter’s next dash', () => { + const s = board(` + ####### + #R..P.# + #######`) + s.enemies[0]!.plan = [v(3, 1)] + expect(safeStep(s, predictDanger(s), v(2, 1))).toBe(false) + expect(safeStep(s, predictDanger(s), v(5, 1))).toBe(true) + }) +}) diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index a9f0257..917eb60 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -53,7 +53,7 @@ const SHOTS = [ { 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') }, + { name: 'enemies', query: 'level=19', act: press('Space', 'Space') }, // Tapped a far tile: the way there in blue, and Move turned into Go; then one Go. { name: 'auto-move', act: async (page) => { await tap(3, 3)(page); await page.click('[data-action=move]') } }, // Held a finger on the tile to the right: a bomb is armed there, with its blast, until release.