explodorama / commit
Deadly moves marked and refused by shortcuts; no tap-to-wait; Acid an area weapon
CHANGELOG.md | 6 ++++ PLAN.md | 16 +++++++++-- README.md | 29 +++++++++++++++---- decisions.md | 31 ++++++++++++++++++++ docs/screenshots/acid.png | Bin 0 -> 225076 bytes docs/screenshots/deadly-move.png | Bin 0 -> 219327 bytes docs/screenshots/deadly-wait.png | Bin 0 -> 219609 bytes src/main.ts | 60 ++++++++++++++++++++++++++++++--------- src/render/board.ts | 34 +++++++++++++++++++++- src/sim/danger.ts | 14 ++++++++- src/sim/demo.ts | 11 +++---- src/sim/explosions.ts | 25 ++++++++++++---- src/sim/picks.ts | 27 +++++++++++++----- src/sim/record.ts | 2 +- src/style.css | 7 +++++ src/ui/clip.ts | 5 ++-- src/ui/controls.ts | 14 +++++++-- src/ui/info.ts | 6 ++-- test/demo.test.ts | 13 ++++++--- test/hazards.test.ts | 13 ++++++++- test/picks.test.ts | 36 ++++++++++++++++++++++- tools/screenshots.mjs | 6 ++++ 22 files changed, 301 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66be43f..868ab1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ saved before the change is dropped and the next visit starts a new run. ### Added +- **Deadly moves are marked:** Wait turns red when waiting would cost you a life, and with a button picked, a tile that would is red with a cross. Taps, keys with nothing picked, the long press and auto-move refuse such a move (the tile flashes red); the button then the tile still makes it. + - **Demo scenes for the board objects** (hiding in a bush, a bush fire, metal stopping flame, an oil spill, crossing spikes, a barrel going off), and a **Board** tab in the almanac listing crates, pillars, every object and rubble, with clips. - **Board objects:** bushes (they hide you from chasers, and burn a bush a turn), metal crates (nothing breaks them), oil spills (flame burns a whole spill at once), spikes (up every other turn) and oil barrels (flame bursts one: oil round it and a blast the next turn), each from its own level on. Version 37: runs saved before don't resume. - **The Acid charge:** a bomb that cracks pillars; a later blast breaks a cracked one, and 4 turns on the ceiling round it falls as rubble for good, crushing what is under it. Two new ways to lose a life: crushed, and walled in (trapped). @@ -96,6 +98,10 @@ saved before the change is dropped and the next visit starts a new run. ### Changed +- **The Acid bomb is an area weapon, like the Nuke:** it burns every tile within 2 steps, through crates, and cracks every pillar in that area, diagonal ones too (it used to burn a cross and crack only a pillar an arm of it ran into). Version 40: runs saved before don't resume. + +- **Tapping yourself no longer waits** (it could kill you by accident); use the Wait button or Space. + - **Boxed in costs a life:** with nowhere to step and nowhere to put a bomb, and nothing left on the board that could change it (a Teleport into a gap between crates, rubble falling round you), you lose a life instead of being stuck for good. Version 39: runs saved before don't resume. - **The danger overlay marks a bomb's tiles from the moment it is placed**, as a red outline until its last two turns, however long the fuse. - **Shield and Flame Pass work by themselves while carried:** no button to press. A hit of their kind uses them up (Flame Pass after 3 fire hits) and the charge slot empties. Version 38: runs saved before don't resume. diff --git a/PLAN.md b/PLAN.md index 8c3e9ae..295e637 100644 --- a/PLAN.md +++ b/PLAN.md @@ -133,7 +133,14 @@ they first appear on). **Touch shortcuts (user request, 2026-10-20; built):** -- **Tap the player** = Wait. +- ~~**Tap the player** = Wait.~~ *Removed 2026-10-26 (user request): a stray tap on yourself + waited, which could kill. A tap on the player now does nothing; Wait is its button (Space).* +- **Deadly moves (user request, 2026-10-26; built):** `isDeadly` (sim/danger.ts) plays an action + on a copy with `step()` and says whether the player loses a life this turn. Wait turns red when + deadly (still pressable); a picked button's deadly tiles are red with a cross (still tappable). + The shortcuts never take a deadly move: `tapMeaning` calls it `'deadly'` (the tile flashes red, + a short buzz), and the same goes for direction keys with nothing picked, the long press for a + bomb, and auto-move. The button then the tile still takes it. One turn ahead only. - **Press and hold** (450 ms) a free tile next to the player, with no action picked = Place bomb there. The bomb and its blast preview appear when the hold registers; release places it, sliding off cancels. @@ -146,7 +153,7 @@ they first appear on). **Tapping the flag also takes one step** (user request, 2026-10-20). Tapping another far tile moves the flag; so does tapping the exit, the power-up or a drop (since 2026-10-22). Recomputed every turn; cleared on arrival, when unreachable, on Esc, by tapping a wall or crate, or on death / level change. Neither Go nor the flag takes a known-deadly step - (onto an enemy, onto an enemy's intent tile, or into flame this turn): the next step shows red + (onto an enemy, onto an enemy's intent tile, into flame this turn, or anything else `isDeadly` finds): the next step shows red and Go falls back to picking Move. **With no enemies left, auto-move walks by itself** (user request, 2026-10-22): one step 1 s after the last action, until the flag is reached — not while an action is picked, "What is @@ -1390,6 +1397,11 @@ the exit or a gem. Crushed or trapped, a Star doesn't help; a Shield carried pus enemy, unreachable from the player through all but walls, metal and rubble (flame can't cross those either, so "reachable by flame" is the same test). Not yet: the acid puddle the bomb leaves (it comes with 19.3's second batch), the floor-collapse alternative. +*Changed 2026-10-26 (user request): **Acid is an area weapon, like the Nuke.** Its flame burns +every tile within `NUKE_RANGE` (2) steps, through anything (the Nuke's diamond, on top of its +cross), and it cracks (or, cracked before, breaks) **every pillar in that diamond**, through +anything, not just the first pillar an arm of the cross runs into: a pillar diagonal to the bomb +was left untouched. The user chose the full Nuke area over cracks-only-in-area. `SIM_VERSION` 40.* ### 19.5 Gauntlet mode diff --git a/README.md b/README.md index b2073e6..c6c0f93 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ seven charges (one carried at a time, used when you choose), a hidden gem on eve mid-run work. Curses and the Reaper enemy are planned — see [PLAN.md](PLAN.md). -Version: **39**, shown at the bottom of the menu with the commit the game was built from (a link +Version: **40**, 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://code.fuzzyworld.net/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 @@ -90,7 +90,26 @@ they make Move and Bomb do more. |---|---|---|---| | **Move** | Tap a tile next to you, or tap **Move** and then any highlighted tile | **M**, then a direction — or W A S D / arrow keys straight away (Q E Z C diagonally, with Diagonal) | Step one tile up, down, left or right onto an empty tile. **With the Run power-up:** or 2 tiles in a straight line. **With Teleport:** or any empty tile up to 3 steps away (4 with Run too), over anything. **With Kick:** walking into a bomb kicks it. **With Diagonal:** or a diagonal step. **With Slide:** or slide in a straight line until a wall, crate or bomb stops you. | | **Bomb** | Tap **Bomb**, then a highlighted tile — or **press and hold** a tile next to you | **B**, then a direction | Place a bomb on an empty tile next to you. You can have 1 bomb on the board at a time. **With Throw:** or 2 tiles away in a straight line, over a crate, bomb or enemy (not a pillar). **With RPG:** or fired in a straight line, as far as it flies. Always a plain bomb: Pierce, Power, Nuke and ICBM are used with the charge button (see Charges). | -| **Wait** | Tap **Wait**, or tap **yourself** | Space | Do nothing; the world takes its turn. | +| **Wait** | Tap **Wait** | Space | Do nothing; the world takes its turn. (Tapping yourself did the same until 2026-10-26; now it does nothing, since a stray tap could kill you.) | + +**Deadly moves are marked, and the shortcuts refuse them** (since 2026-10-26). The game plays +each choice out on a copy of the board, with the real rules, to see whether it would cost you a +life **this turn** (a Shield or Flame Pass that would take the hit, or an Immortal run, means it +wouldn't): + +- **Wait turns red** when waiting would kill you. It still works when pressed: sometimes there + is nothing better. +- With a button picked, a tile that would kill you is **red with a cross** instead of blue. You + can still tap it, on purpose. +- The **shortcuts never take a deadly move**: a tap on a tile with nothing picked, W A S D / the + arrow keys / Q E Z C with nothing picked, a long press for a bomb, and auto-move (Go, or a tap on + the destination). The tile flashes red (and the phone buzzes) instead. To make such a move + anyway, pick its button first (Move, then the tile). + +It only looks one turn ahead: walking into a dead end that a blast will fill next turn isn't +marked; the danger overlay shows that. + +<img src="docs/screenshots/deadly-move.png" alt="Move picked next to a bomb about to go off: the tile back up into its blast is red with a cross, the safe tile below blue" width="260"> When Move or Bomb can do several things, the tile you tap decides: next to you is a step (or a kick, into a bomb), 2 straight is a run, further is where a slide stops. Without picking anything, a tap on the tile where a slide stops slides there; next to you is a bomb, 2 away a throw, further an ICBM while you have launches, otherwise an @@ -110,8 +129,8 @@ new way. Auto-move ends when you arrive, when you **move somewhere off the blue changed your mind), when the destination can no longer be reached, when you tap a wall or crate, or on **Esc**. The way **never crosses the open exit** (that would end the level on the way); it only steps onto the exit when the exit is where you are going. If the only way somewhere is over the open exit, auto-move can't go there. -It **won't take a deadly step**: onto an enemy, onto a tile an enemy's arrow points at, or into -flame this turn. The next step then shows red, and Go / the destination wait for you to deal with it +It **won't take a deadly step**: onto an enemy, onto a tile an enemy's arrow points at, into +flame this turn, or any other move that would cost a life this turn. The next step then shows red, and Go / the destination wait for you to deal with it (Go falls back to picking Move, so you can step aside yourself). **Once every enemy is gone**, the way needs no more presses: you walk there by yourself, two steps a second (still one turn per step). @@ -188,7 +207,7 @@ piled up). The top bar shows it too. | **ICBM** | bomb with a target badge | 3 launches: the charge button fires a bomb as an RPG's (or **I**, then a direction) and it goes off the moment it lands, at any distance. Your bomb limit doesn't count. | | **Flame Pass** | flame with a shield badge | **Works by itself while you carry it:** flame can't hurt you for the next **3 fire hits** (an orange ring round you; the charge button and the top bar count them), level after level; when they are used the charge slot is empty. Enemies still can. A power-up that worked for good until 2026-10-25, when the balance lab found it won the game alone. | | **Teleport** | the Move figure with an outbox badge | **3 jumps**: press the charge button (or **P**), then tap any empty tile within 3 steps (counting across and down), over anything. **With Run, 4 steps;** **with Diagonal, a square** (a diagonal step counts as one). A power-up until 2026-10-25, for the same reason. | -| **Acid** | bomb with a flask badge | **1 bomb** whose flame also **cracks** every pillar it reaches (cracks show on it). **Any later blast** that reaches a cracked pillar **breaks** it: its tile becomes floor, dust trickles from the ceiling over the 8 tiles round it, and **4 turns later** it falls as **rubble**, for good — nothing gets through rubble, not even flame. Whatever is under it is crushed: enemies (a trap to set), drops, bombs, and **you** — unless you carry a Shield, which pushes you out to an empty tile next to you (with none, nothing saves you; a Star doesn't). If the rubble cuts you off from the exit or from an enemy, the level can't be won: that costs a life at once (**trapped**). The outer wall never breaks. Since 2026-10-25. | +| **Acid** | bomb with a flask badge | **1 bomb** that burns like a Nuke — **every tile within 2 steps** (across plus down), through crates and round corners — and **cracks every pillar** in that area, diagonal ones included (cracks show on it; since 2026-10-26: until then it burned a plain cross and cracked only a pillar an arm of it ran into). It can catch you too: get 3 steps away. **Any later blast** that reaches a cracked pillar **breaks** it: its tile becomes floor, dust trickles from the ceiling over the 8 tiles round it, and **4 turns later** it falls as **rubble**, for good — nothing gets through rubble, not even flame. Whatever is under it is crushed: enemies (a trap to set), drops, bombs, and **you** — unless you carry a Shield, which pushes you out to an empty tile next to you (with none, nothing saves you; a Star doesn't). If the rubble cuts you off from the exit or from an enemy, the level can't be won: that costs a life at once (**trapped**). The outer wall never breaks. Since 2026-10-25. | A Pierce, Power, Nuke or Acid bomb wears its charge's icon as a small badge. **Bomb** always puts down a plain bomb. diff --git a/decisions.md b/decisions.md index da0633a..61801a7 100644 --- a/decisions.md +++ b/decisions.md @@ -2210,3 +2210,34 @@ Desk is going away. `BUG_EMAIL` is now `bugs-k7q4+explodorama@fuzzyworld.net`: o address shared by Pavel's apps, with `+explodorama` telling them apart. The random part makes the address rotatable if it draws spam. (The old address was the *fuzzyworld* project's Service Desk, not this game's.) The email itself is unchanged. + +### 2026-10-26 — No tap-to-wait; deadly moves marked, and refused by the shortcuts + +User request: a tap on the player waited, which could be deadly; so could a tap in front of an +enemy or into flame. Asked: remove the tap-yourself shortcut, and "mark or disallow" moves that +guarantee a death, a button in red if pressing it would. +- **A tap on yourself does nothing.** Wait is its button (Space). The Freeze charge's + confirming tap on yourself, with the charge button picked, stays. +- **Deadly** means the player loses a life **this turn**, found by playing the action on a copy + with `step()` (`isDeadly`, sim/danger.ts), so it can't disagree with the rules: a Shield or Flame + Pass taking the hit, or Immortal, isn't deadly. Only one turn ahead: "every follow-up dies too" + (a dead end a blast fills next turn) would need a search, and later enemy moves aren't known yet. +- **Both mark and disallow, split by how deliberate the input is.** Shortcuts (a tap with + nothing picked, direction keys with nothing picked, the long press, auto-move) refuse a deadly + move, with a red flash and a buzz. Deliberate inputs only mark it: Wait turns red, a picked + button's deadly tiles are red with a cross, and both still act. Disallowing those too could + leave a player with no move at all (every choice deadly, e.g. boxed in), and dying on purpose + is the player's call. +- The demo's gestures follow the same rule (a deadly step is shown as Move then the tile), so + what it shows still works in play. No `SIM_VERSION` change: the rules are the same. + +### 2026-10-26 — Acid becomes an area weapon + +User report: an acid bomb placed diagonal to pillars left them untouched, since its flame was a +plain cross and only the first pillar an arm ran into cracked. Asked for "an area weapon, like the +nuke". Offered three readings (cracks in the Nuke's diamond with the usual cross of flame; the full +Nuke area; cracks in the 8 tiles round it); **the user chose the full Nuke area**: the acid bomb +burns every tile within `NUKE_RANGE` (2) steps, through anything, and every pillar in that diamond +is reached (cracked, or broken if cracked before). It is now a Nuke plus cracks in a 1-bomb charge +(the Nuke gives 3 bombs); its price (6) is unchanged, to revisit with the balance lab if it proves +too strong. The Nuke itself still breaks a cracked pillar only along its cross. `SIM_VERSION` 40. diff --git a/docs/screenshots/acid.png b/docs/screenshots/acid.png new file mode 100644 index 0000000..76f03b7 Binary files /dev/null and b/docs/screenshots/acid.png differ diff --git a/docs/screenshots/deadly-move.png b/docs/screenshots/deadly-move.png new file mode 100644 index 0000000..fbefe43 Binary files /dev/null and b/docs/screenshots/deadly-move.png differ diff --git a/docs/screenshots/deadly-wait.png b/docs/screenshots/deadly-wait.png new file mode 100644 index 0000000..74151c0 Binary files /dev/null and b/docs/screenshots/deadly-wait.png differ diff --git a/src/main.ts b/src/main.ts index cdb6a42..e45c881 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,10 +24,10 @@ import { animateTurn as animateBoard, clearAnimation, newAnimation, pruneAnimati import { endPan, panView, resetPan, updateCamera, viewRect } from './render/camera' import { CHARGE_ICONS, MONO_ICONS, POWER_UP_ICONS, TEXTURES } from './render/sprites' import { isAvailable, isValidTarget, ACTIONS } from './sim/actions' -import { canPick, membersOf, resolvePick, tapMeaning } from './sim/picks' +import { canPick, membersOf, pickTargets, resolvePick, tapMeaning } from './sim/picks' import { enemyAt } from './sim/enemies' import { autoMoveAction, autoRoute, playerPath, safeStep as isSafeStep } from './sim/path' -import { predictDanger, type Danger } from './sim/danger' +import { isDeadly, predictDanger, type Danger } from './sim/danger' import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record' import { cellAt, eq, inBounds, isBurning, newRun, PAR_BONUS } from './sim/state' import { LAST_LEVEL } from './sim/levelgen' @@ -76,6 +76,15 @@ let snapCamera = true /** Auto-move: a far tile the player tapped. Each press of Move takes one step along `path` toward it. */ let destination: Vec | null = null let path: Vec[] | null = null +/** + * What would cost a life this turn (sim/danger.ts `isDeadly`; user request, 2026-10-26): Wait, + * the picked button's tiles, and auto-move's next move. Worked out once a turn, not every frame. + */ +let deadlyWait = false +let deadlyTargets: Vec[] = [] +let deadlyAutoMove = false +/** A tap refused because it would cost a life: the tile flashes red for a moment. */ +let refused: { tile: Vec; at: number } | null = null /** Long press: the adjacent tile a held finger has armed for a bomb, placed on release. */ let armed: Vec | null = null let pressTimer: number | undefined @@ -305,13 +314,24 @@ function afterChange(): void { if (destination && (eq(state.player, destination) || state.status !== 'playing')) destination = null path = destination ? playerPath(state, destination) : null if (!path) destination = null - controls.update(state, selected, destination !== null, inspecting) + deadlyWait = isAvailable(state, 'wait') && isDeadly(state, { id: 'wait' }) + const picked = selected + deadlyTargets = picked + ? pickTargets(state, picked).filter((p) => { + const id = resolvePick(state, picked, p) + return !!id && isDeadly(state, { id, target: p }) + }) + : [] + const next = destination && autoMoveAction(state, destination, safeStep) + deadlyAutoMove = !!next && isDeadly(state, next) + controls.update(state, selected, destination !== null, inspecting, deadlyWait) } /** * Two taps: pick an action, then a highlighted tile. With "What is that?" on: explain the tile. - * With nothing picked: - * - the player: Wait; + * With nothing picked (never a move that would cost a life: `tapMeaning` calls it 'deadly', and + * the tile flashes red; the button and then the tile still take it): + * - the player: nothing (it was Wait until 2026-10-26, user request: it could kill); * - the auto-move flag: one step toward it (like Go), with auto-move's safety check; * - a tile next to the player: move there; with Diagonal, a diagonal neighbour too (user report, * 2026-10-23: it was walked to round the corner, as auto-move); @@ -341,7 +361,7 @@ function tapTile(p: Vec): void { } else { // What a tap means is worked out in the sim (picks.ts `tapMeaning`), shared with the demo. const means = tapMeaning(state, p) - if (means === 'wait') pick('wait') + if (means === 'deadly') refuse(p) else if (means === 'detonate' || means === 'kick') act({ id: means, target: p }) else if (means === 'blocked') return else if (destination && eq(destination, p)) stepTowardDestination() @@ -354,6 +374,12 @@ function tapTile(p: Vec): void { } } +/** A tap or key that would cost a life, not taken: the tile flashes red, and a short buzz. */ +function refuse(p: Vec): void { + refused = { tile: p, at: time } + vibrate([30, 40, 30]) +} + /** The "What is that?" button: on, or off again (dropping any action picked). */ function inspect(): void { if (state.status !== 'playing') return @@ -551,12 +577,12 @@ function safeStep(p: Vec): boolean { /** * One move of auto-move: a step, a Run or a Teleport, whichever gets closest (`autoMoveAction`), - * only onto safe tiles. True if it moved. + * only onto safe tiles, and never one that would cost a life (`isDeadly`). True if it moved. */ function stepTowardDestination(): boolean { if (!destination) return false const action = autoMoveAction(state, destination, safeStep) - if (!action) return false + if (!action || isDeadly(state, action)) return false act(action, true) return true } @@ -605,7 +631,10 @@ function direction(d: Dir): void { for (const reach of reaches) { const p = { x: state.player.x + reach * DIRS[d].x, y: state.player.y + reach * DIRS[d].y } const id = resolvePick(state, picked, p) - if (id) return act({ id, target: p }) + if (!id) continue + // Nothing picked, a key is a shortcut, as a tap is: never one that costs a life. + if (!selected && isDeadly(state, { id, target: p })) return refuse(p) + return act({ id, target: p }) } } @@ -652,7 +681,9 @@ window.addEventListener('keydown', (e) => { const diag = diagonalKeys ? DIAGONAL_KEYS[e.code] : undefined if (diag) { const p = { x: state.player.x + diag.x, y: state.player.y + diag.y } - if (isValidTarget(state, 'diagonal', p)) act({ id: 'diagonal', target: p }) + if (!isValidTarget(state, 'diagonal', p)) return + if (!selected && isDeadly(state, { id: 'diagonal', target: p })) refuse(p) + else act({ id: 'diagonal', target: p }) } else if (d) direction(d) else if (e.code === 'Space') pick('wait') else if (e.key === '?' || e.code === 'Slash') inspect() @@ -683,6 +714,8 @@ function startPress(p: Vec | null): void { armed = null pressTile = p if (!p || selected || inspecting || state.status !== 'playing' || !isValidTarget(state, 'bomb', p)) return + // A shortcut, as a tap is: never a bomb that costs a life (with a fuse of 1). + if (isDeadly(state, { id: 'bomb', target: p })) return pressTimer = window.setTimeout(() => { armed = p navigator.vibrate?.(15) @@ -1385,9 +1418,10 @@ await engineInit( // A dialog's clip first: it uses the canvas for a moment, then clears it (ui/clip.ts). drawClips() drawBoard({ - state, danger, selected, hover, destination, path, armed, ...anim, - route: destination ? autoRoute(state, destination, safeStep) : null, - pathBlocked: !!path?.[0] && !safeStep(path[0]), + state, danger, selected, hover, destination, path, armed, ...anim, deadly: deadlyTargets, refused, + // A deadly next move shows as a blocked one: the first tile red. + route: destination && !deadlyAutoMove ? autoRoute(state, destination, safeStep) : null, + pathBlocked: (!!path?.[0] && !safeStep(path[0])) || deadlyAutoMove, }) // What is out of sight on a board bigger than the view: arrows at its edge. if (!demo) drawIndicators(state, viewRect()) diff --git a/src/render/board.ts b/src/render/board.ts index 6955fb0..0abea13 100644 --- a/src/render/board.ts +++ b/src/render/board.ts @@ -60,6 +60,10 @@ export interface View { pathBlocked: boolean /** A tile a long press has armed for a bomb. */ armed: Vec | null + /** The picked button's tiles that would cost a life this turn (sim/danger.ts `isDeadly`): red. */ + deadly?: Vec[] + /** A tap refused because it would cost a life, and when (LittleJS `time`): it flashes red. */ + refused?: { tile: Vec; at: number } | null /** The player's latest move (from their change of tile) and pose, drawn move first (effects.ts). */ playerMove: PlayerMove | null playerPose: PlayerPose @@ -91,6 +95,10 @@ const C = { /** Auto-move's way, drawn as a line of arrows over its blue tiles. */ pathLine: rgb(0.13, 0.42, 0.72, 0.9), targetHover: rgb(0.25, 0.62, 0.87, 0.7), + /** A picked button's tile that would cost a life: red with a cross, not blue. */ + deadly: rgb(0.8, 0.1, 0.08, 0.5), + deadlyHover: rgb(0.8, 0.1, 0.08, 0.75), + deadlyCross: rgb(0.55, 0.05, 0.03, 0.9), preview: rgb(1, 0.55, 0.1, 0.4), flame: rgb(1, 0.6, 0.1), white: rgb(1, 1, 1), @@ -131,6 +139,16 @@ const STONE_OUTLINE = 8 / 128 export const BACKGROUND: Color = C.sky +/** How long a refused tap's tile flashes red. */ +const REFUSED_SECONDS = 0.6 + +/** An X across a tile: "this would cost a life". */ +function drawCross(at: Vector2, color: Color): void { + const r = 0.28 + drawLine(at.add(vec2(-r, -r)), at.add(vec2(r, r)), 0.09, color) + drawLine(at.add(vec2(-r, r)), at.add(vec2(r, -r)), 0.09, color) +} + export function drawBoard(view: View): void { const { state: s, danger, selected, hover, fx } = view // A hair over a tile, so neighbouring walls overlap and no anti-aliased seam shows between them. @@ -217,7 +235,21 @@ export function drawBoard(view: View): void { // The picked button's tiles: every action it stands for (sim/picks.ts). const targets = selected ? pickTargets(s, selected) : [] const hovered = selected && hover && targets.some((t) => eq(t, hover)) ? hover : null - for (const t of targets) drawRect(toWorld(s, t), vec2(0.94), hovered && eq(t, hovered) ? C.targetHover : C.target) + for (const t of targets) { + const on = !!hovered && eq(t, hovered) + // One that would cost a life is red with a cross (user request, 2026-10-26): it can still be + // taken, on purpose. + if (view.deadly?.some((d) => eq(d, t))) { + drawRect(toWorld(s, t), vec2(0.94), on ? C.deadlyHover : C.deadly) + drawCross(toWorld(s, t), C.deadlyCross) + } else drawRect(toWorld(s, t), vec2(0.94), on ? C.targetHover : C.target) + } + // A tap refused because it would cost a life: the tile flashes red, then fades. + const flash = view.refused ? 1 - (time - view.refused.at) / REFUSED_SECONDS : 0 + if (view.refused && flash > 0) { + drawRect(toWorld(s, view.refused.tile), vec2(0.94), C.deadlyHover.scale(1, flash)) + drawCross(toWorld(s, view.refused.tile), C.deadlyCross.scale(1, flash)) + } // Auto-move's way: every tile of it highlighted blue, the destination too, with no flag or dots // (user request, 2026-10-23). The next step is red when it isn't safe to take. // Since 2026-10-25 it shows where the player will land, move by move: with Run or Teleport, only diff --git a/src/sim/danger.ts b/src/sim/danger.ts index 42bc8dc..a1965f2 100644 --- a/src/sim/danger.ts +++ b/src/sim/danger.ts @@ -1,6 +1,7 @@ import { collapse, detonate, proximityAlert, tickBombs } from './explosions' import { cloneState } from './state' -import type { GameEvent, GameState } from './types' +import { step } from './turn' +import type { Action, GameEvent, GameState } from './types' /** Tile indices that will burn at the end of this turn, and at the end of the next one. */ export interface Danger { @@ -57,3 +58,14 @@ export function predictDanger(state: GameState): Danger { for (const i of [...now, ...next]) later.delete(i) return { now, next, later } } + +/** + * Whether `action` costs the player a life this turn (user request, 2026-10-26: a tap in front + * of an enemy, or into flame, did). It is played on a copy with `step()`, so it can't disagree + * with what happens: a Shield or Flame Pass that takes the hit, or an Immortal run, means it + * doesn't. False while no turn can be taken (an offer open, the level over). + */ +export function isDeadly(s: GameState, action: Action): boolean { + if (s.status !== 'playing' || s.offer || s.chargeOffer) return false + return step(s, action).events.some((e) => e.type === 'playerDied') +} diff --git a/src/sim/demo.ts b/src/sim/demo.ts index 301eb69..47c86d2 100644 --- a/src/sim/demo.ts +++ b/src/sim/demo.ts @@ -1,5 +1,5 @@ import { ACTIONS, isAvailable, isValidTarget } from './actions' -import { predictDanger } from './danger' +import { isDeadly, predictDanger } from './danger' import { blastTiles } from './explosions' import { planIntents } from './enemies' import { Rng } from './rng' @@ -192,13 +192,13 @@ export const SCENES: Record<SceneKind, SceneDef> = { if (!far.length) return null const to = far[rng.int(far.length)]! const action = autoMoveAction(s, to, safe) - if (!action) return null + if (!action || isDeadly(s, action)) return null walking.to = to return { action, gesture: { kind: 'walk', tile: to } } } - // Not safe yet: wait for the way to clear (the destination stays). + // Not safe yet: wait for the way to clear (the destination stays). Go never takes a deadly move. const action = autoMoveAction(s, walking.to, safe) - return action ? { action, gesture: { kind: 'go' } } : { action: { id: 'wait' }, gesture: { kind: 'button', button: 'wait' } } + return action && !isDeadly(s, action) ? { action, gesture: { kind: 'go' } } : { action: { id: 'wait' }, gesture: { kind: 'button', button: 'wait' } } }, shows: (t, seen) => !!seen.walk && at(t.next.player, seen.walk), }, @@ -855,7 +855,8 @@ function gestureFor(def: SceneDef, s: GameState, action: Action): Gesture | null if (action.id === 'wait') return { kind: 'button', button: 'wait' } if (!('target' in action) || !action.target) return null const tile = action.target - if (action.id === 'bomb' && !def.bombButton) return { kind: 'hold', tile } + // A long press never puts down a bomb that costs a life (main.ts `startPress`): its button does. + if (action.id === 'bomb' && !def.bombButton && !isDeadly(s, action)) return { kind: 'hold', tile } if (TAPPED.has(action.id) && tapMeaning(s, tile) === action.id) return { kind: 'tap', tile } const button: ButtonId = membersOf('move').includes(action.id) ? 'move' : membersOf('charged').includes(action.id) ? 'charged' : 'bomb' return resolvePick(s, button, tile) === action.id ? { kind: 'button', button, tile } : null diff --git a/src/sim/explosions.ts b/src/sim/explosions.ts index 7454afc..ebc34f5 100644 --- a/src/sim/explosions.ts +++ b/src/sim/explosions.ts @@ -7,9 +7,10 @@ import { DIRS, DIR_LIST, isSolid, type Bomb, type GameEvent, type GameState, typ * Flame stops before a wall, metal crate or rubble, and stops *on* a crate, an oil barrel or * another bomb (burning it). A Pierce bomb's flame carries on through crates, not barrels. A * Nuke bomb also burns every tile within NUKE_RANGE steps (across plus down), through anything, - * except what is solid, which still stands. + * except what is solid, which still stands. So does an Acid bomb (user request, 2026-10-26: an + * area weapon like the Nuke; until then it burned a plain cross). */ -export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'pierce' | 'nuke'>): Vec[] { +export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'pierce' | 'nuke' | 'acid'>): Vec[] { const tiles = [bomb.pos] for (const d of DIR_LIST) { let p = bomb.pos @@ -21,7 +22,7 @@ export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'pier if ((cell === 'block' && !bomb.pierce) || cell === 'barrel' || bombAt(s, p)) break } } - if (bomb.nuke) { + if (bomb.nuke || bomb.acid) { for (let dy = -NUKE_RANGE; dy <= NUKE_RANGE; dy++) { for (let dx = -NUKE_RANGE; dx <= NUKE_RANGE; dx++) { const p = add(bomb.pos, { x: dx, y: dy }) @@ -38,15 +39,27 @@ export function isPillar(s: GameState, p: Vec): boolean { return cellAt(s, p) === 'wall' && p.x > 0 && p.y > 0 && p.x < s.width - 1 && p.y < s.height - 1 } -/** The pillars a bomb's flame runs into: where a flame, still within reach, stops at one. */ -export function pillarsReached(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'pierce'>): Vec[] { +/** + * The pillars a bomb's flame runs into: where a flame, still within reach, stops at one. An Acid + * bomb's also reaches every pillar within NUKE_RANGE steps, through anything (user request, + * 2026-10-26: one diagonal to it was left untouched). + */ +export function pillarsReached(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'pierce' | 'acid'>): Vec[] { const pillars: Vec[] = [] + if (bomb.acid) { + for (let dy = -NUKE_RANGE; dy <= NUKE_RANGE; dy++) { + for (let dx = -NUKE_RANGE; dx <= NUKE_RANGE; dx++) { + const p = add(bomb.pos, { x: dx, y: dy }) + if (Math.abs(dx) + Math.abs(dy) <= NUKE_RANGE && isPillar(s, p)) pillars.push(p) + } + } + } for (const d of DIR_LIST) { let p = bomb.pos for (let i = 0; i < bomb.fire; i++) { p = add(p, DIRS[d]) const cell = cellAt(s, p) - if (isPillar(s, p)) pillars.push(p) + if (isPillar(s, p) && !pillars.some((q) => eq(q, p))) pillars.push(p) if (isSolid(cell) || (cell === 'block' && !bomb.pierce) || cell === 'barrel' || bombAt(s, p)) break } } diff --git a/src/sim/picks.ts b/src/sim/picks.ts index f1de6cb..969444a 100644 --- a/src/sim/picks.ts +++ b/src/sim/picks.ts @@ -1,4 +1,5 @@ import { ACTIONS, isAvailable, isValidTarget } from './actions' +import { isDeadly } from './danger' import { enemyAt } from './enemies' import { playerPath } from './path' import { bombAt, cellAt, eq, flameSafe, isBurning } from './state' @@ -49,19 +50,31 @@ export function canPick(s: GameState, picked: ActionId): boolean { } /** What a tap on a tile means with no button picked (`tapMeaning`). */ -export type TapMeaning = 'wait' | 'detonate' | 'kick' | 'blocked' | 'move' | 'diagonal' | 'slide' | 'walk' | 'teleport' +export type TapMeaning = 'deadly' | 'detonate' | 'kick' | 'blocked' | 'move' | 'diagonal' | 'slide' | 'walk' | 'teleport' + +/** The meanings that are an action on the tapped tile, played at once. */ +const TAP_ACTIONS: ReadonlySet<TapMeaning> = new Set(['detonate', 'kick', 'move', 'diagonal', 'slide', 'teleport']) /** * What a tap on `p` means with nothing picked (main.ts `tapTile`; also the demo's taps), first - * match wins: the player waits; one of your bombs, with the Detonator, goes off; a bomb next to - * you, with Kick, is kicked; an enemy, any other bomb, or flame that can hurt you is 'blocked' - * (never a move into it); then a step, a diagonal step, the tile where a slide stops (with Slide: - * user report, 2026-10-24, it walked there instead), a far tile you can walk to ('walk': it - * becomes auto-move's destination), or one you can only teleport to. Null: it means nothing. + * match wins: the player's own tile is 'blocked' (it was Wait until 2026-10-26, user request: + * a tap on yourself waited, which could kill); one of your bombs, with the Detonator, goes off; a + * bomb next to you, with Kick, is kicked; an enemy, any other bomb, or flame that can hurt you is + * 'blocked' (never a move into it); then a step, a diagonal step, the tile where a slide stops + * (with Slide: user report, 2026-10-24, it walked there instead), a far tile you can walk to + * ('walk': it becomes auto-move's destination), or one you can only teleport to. Null: it means + * nothing. An action that would cost a life this turn is 'deadly' instead (`isDeadly`, user + * request, 2026-10-26): a tap never takes it; its button still can. * Auto-move's own destination is the UI's to check: a tap on it is its next step. */ export function tapMeaning(s: GameState, p: Vec): TapMeaning | null { - if (eq(p, s.player)) return 'wait' + const means = plainMeaning(s, p) + return means && TAP_ACTIONS.has(means) && isDeadly(s, { id: means as ActionId, target: p }) ? 'deadly' : means +} + +/** `tapMeaning`, before asking whether it is deadly. */ +function plainMeaning(s: GameState, p: Vec): TapMeaning | null { + if (eq(p, s.player)) return 'blocked' if (isAvailable(s, 'detonate') && isValidTarget(s, 'detonate', p)) return 'detonate' if (isAvailable(s, 'kick') && isValidTarget(s, 'kick', p)) return 'kick' if (enemyAt(s, p) || bombAt(s, p) || (isBurning(s, p) && !flameSafe(s))) return 'blocked' diff --git a/src/sim/record.ts b/src/sim/record.ts index 0321dc1..fbf137e 100644 --- a/src/sim/record.ts +++ b/src/sim/record.ts @@ -6,7 +6,7 @@ import { DEFAULT_OPTIONS, type Action, type ChargeId, type GameEvent, type GameS * 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 = 39 +export const SIM_VERSION = 40 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */ export interface RunRecord { diff --git a/src/style.css b/src/style.css index 5500094..cabd1ba 100644 --- a/src/style.css +++ b/src/style.css @@ -274,6 +274,13 @@ img { box-shadow: 0 4px 0 var(--accent-dark); } +/* Wait, when waiting would cost a life (user request, 2026-10-26): red, like the danger overlay. */ +#actions > button.deadly:not(:disabled) { + border-color: #c0392b; + background: #ffd9d4; + box-shadow: 0 4px 0 #8e2a20; +} + #actions > button[aria-pressed='true'] { background: var(--accent); color: #fff; diff --git a/src/ui/clip.ts b/src/ui/clip.ts index b271e83..d5d258d 100644 --- a/src/ui/clip.ts +++ b/src/ui/clip.ts @@ -180,12 +180,13 @@ function play(c: Clip, action: Action): void { /** * A turn as the finger plays it (as the demo's, main.ts `beatsFor`, on the clip's own board): to * the tile, a tap or a long press — after a button, its tiles lit — and the turn on letting go. - * Wait is a tap on the player (which waits, as in play); the action bar isn't in the clip. + * The action bar isn't in the clip, so Wait (its button) is just played, with no finger (a tap on + * the player waited until 2026-10-26). */ function gestureBeats(c: Clip, g: Gesture, action: Action, last: boolean): Clip['beats'] { const s = c.state! // A choice in a dialog (the charge swap) has no tile on the board: the clip just plays it. - const tile = g.kind === 'go' || g.kind === 'choice' ? null : g.kind === 'button' ? (g.tile ?? s.player) : g.tile + const tile = g.kind === 'go' || g.kind === 'choice' ? null : g.kind === 'button' ? (g.tile ?? (g.button === 'wait' ? null : s.player)) : g.tile if (!tile) return [{ run: () => play(c, action), then: last ? HOLD_SECONDS : STEP_SECONDS }] const finger = c.finger! const hold = g.kind === 'hold' diff --git a/src/ui/controls.ts b/src/ui/controls.ts index e3c4fea..d24bb1b 100644 --- a/src/ui/controls.ts +++ b/src/ui/controls.ts @@ -161,8 +161,11 @@ export function createControls(handlers: Handlers) { } return { - /** `autoMove`: a destination is set, so Move takes one step toward it. `inspecting`: "What is that?" is on. */ - update(s: GameState, selected: ActionId | null, autoMove: boolean, inspecting: boolean): void { + /** + * `autoMove`: a destination is set, so Move takes one step toward it. `inspecting`: "What is + * that?" is on. `deadlyWait`: waiting would cost a life this turn. + */ + update(s: GameState, selected: ActionId | null, autoMove: boolean, inspecting: boolean, deadlyWait = false): void { inspectButton.setAttribute('aria-pressed', String(inspecting)) inspectButton.disabled = s.status !== 'playing' current = s @@ -187,6 +190,13 @@ export function createControls(handlers: Handlers) { b.setAttribute('aria-label', kind ? `${name} charge, ${s.charge!.left} left` : 'Charge: none carried') } } + if (def.id === 'wait') { + // Red when it would cost a life (user request, 2026-10-26). It still waits: with + // nothing else to do, that is the player's to choose. + b.classList.toggle('deadly', deadlyWait) + b.title = deadlyWait ? 'Wait (deadly!)' : def.label + b.setAttribute('aria-label', deadlyWait ? 'Wait: you would die' : def.label) + } if (def.id === 'move') { // Auto-move: Move has become Go, one step toward the destination per press. It keeps // the walk icon (user request, 2026-10-23: it used to turn into a flag); a green diff --git a/src/ui/info.ts b/src/ui/info.ts index b558901..7608bd2 100644 --- a/src/ui/info.ts +++ b/src/ui/info.ts @@ -75,7 +75,7 @@ export const CHARGE_TEXT: Record<ChargeId, { name: string; text: string }> = { icbm: { name: 'ICBM', text: `${BOMB_BOOST} launches: the charge button fires a bomb as far as it flies, as an RPG's, and it goes off the moment it lands. Your bomb limit doesn't count.` }, // Power-ups until 2026-10-25 (the balance lab found each won the game alone): charges now. flamePass: { name: 'Flame Pass', text: `Works by itself while you carry it: flame can’t hurt you for the next ${FLAME_PASS_HITS} fire hits (and you may walk into flame), level after level. When they are used up the charge slot is empty. Enemies still can hurt you.` }, - acid: { name: 'Acid', text: `One bomb whose flame cracks every pillar it reaches. Any blast that reaches a cracked pillar breaks it, and ${COLLAPSE_TURNS} turns later the ceiling round it comes down as rubble, for good: anything under it is crushed, you too (a Shield you carry pushes you out to an empty tile next to you). Don’t wall yourself in: if rubble cuts you off from the exit or an enemy, you lose a life. Put it down with the charge button.` }, + acid: { name: 'Acid', text: `One bomb that burns every tile within ${NUKE_RANGE} steps, as a Nuke does, through crates and round corners, and cracks every pillar in that area. Get ${NUKE_RANGE + 1} steps away. Any blast that reaches a cracked pillar breaks it, and ${COLLAPSE_TURNS} turns later the ceiling round it comes down as rubble, for good: anything under it is crushed, you too (a Shield you carry pushes you out to an empty tile next to you). Don’t wall yourself in: if rubble cuts you off from the exit or an enemy, you lose a life. Put it down with the charge button.` }, teleport: { name: 'Teleport', text: `${CHARGE_AMOUNT.teleport} jumps: press the charge button, then tap any empty tile up to ${TELEPORT_RANGE} steps away, over anything (${TELEPORT_RANGE + 1} with Run; with Diagonal, a diagonal step counts as one).` }, } @@ -360,7 +360,7 @@ export function tileInfo(s: GameState, p: Vec): Info | null { `Its blast reaches ${reach} in each direction. Flame stops at walls${bomb.pierce ? ' (it goes through crates)' : ' and on the first crate'}, and sets off any bomb it reaches.`, ] if (bomb.nuke) body.push(`Nuke: it also burns every tile within ${NUKE_RANGE} steps, through crates and round corners.`) - if (bomb.acid) body.push('Acid: its flame cracks every pillar it reaches.') + if (bomb.acid) body.push(`Acid: it burns every tile within ${NUKE_RANGE} steps, and cracks every pillar in that area.`) // Its clip: the charge that shapes its blast, or a bomb's countdown and blast. const clip = bomb.nuke ? 'nuke' : bomb.power ? 'power' : bomb.pierce ? 'pierce' : bomb.acid ? 'acid' : 'kill' return { icon: 'bomb', title: `${kinds.length ? `${kinds.join(' + ')} bomb` : 'Bomb'}: ${bomb.timer}`, body, clip } @@ -535,7 +535,7 @@ export function actionInfo(s: GameState, picked: ActionId): Info { return { icon: CHARGE_ICONS[kind], title: `Charge: ${CHARGE_TEXT[kind].name}`, body, clip: kind } } case 'wait': - return { icon: 'icon_wait', title: 'Wait', body: ['Do nothing; the world takes its turn. Tapping yourself does the same.'], clip: 'wait' } + return { icon: 'icon_wait', title: 'Wait', body: ['Do nothing; the world takes its turn. The button turns red when waiting would cost you a life.'], clip: 'wait' } } } diff --git a/test/demo.test.ts b/test/demo.test.ts index 36165af..b0b1e29 100644 --- a/test/demo.test.ts +++ b/test/demo.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { isValidTarget } from '../src/sim/actions' -import { predictDanger } from '../src/sim/danger' +import { isDeadly, predictDanger } from '../src/sim/danger' import { generateScene, MAX_STEPS, MIN_STEPS, replayScene, SCENE_KINDS, SCENES, showsAtEnd, type Gesture } from '../src/sim/demo' import { autoMoveAction, safeStep } from '../src/sim/path' import { resolvePick, tapMeaning } from '../src/sim/picks' @@ -19,9 +19,14 @@ function controlsTake(s: GameState, g: Gesture, destination: Vec | null): Action ? { id: means, target: g.tile } : null } - if (g.kind === 'hold') return isValidTarget(s, 'bomb', g.tile) ? { id: 'bomb', target: g.tile } : null - if (g.kind === 'walk') return tapMeaning(s, g.tile) === 'walk' ? autoMoveAction(s, g.tile, safe) : null - if (g.kind === 'go') return destination && autoMoveAction(s, destination, safe) + if (g.kind === 'hold') return isValidTarget(s, 'bomb', g.tile) && !isDeadly(s, { id: 'bomb', target: g.tile }) ? { id: 'bomb', target: g.tile } : null + // Auto-move never takes a move that costs a life. + const auto = (to: Vec) => { + const a = autoMoveAction(s, to, safe) + return a && !isDeadly(s, a) ? a : null + } + if (g.kind === 'walk') return tapMeaning(s, g.tile) === 'walk' ? auto(g.tile) : null + if (g.kind === 'go') return destination && auto(destination) if (g.kind === 'choice') return s.chargeOffer ? { id: g.swap ? 'swapCharge' : 'sellCharge' } : null if (g.button === 'wait') return { id: 'wait' } const id = g.tile && resolvePick(s, g.button, g.tile) diff --git a/test/hazards.test.ts b/test/hazards.test.ts index f224dbe..afb05be 100644 --- a/test/hazards.test.ts +++ b/test/hazards.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { predictDanger } from '../src/sim/danger' import { stepToward } from '../src/sim/enemies' -import { COLLAPSE_TURNS } from '../src/sim/explosions' +import { blastTiles, COLLAPSE_TURNS } from '../src/sim/explosions' import { generateLevel, OBJECTS_FROM } from '../src/sim/levelgen' import { idx } from '../src/sim/state' import { step } from '../src/sim/turn' @@ -169,6 +169,17 @@ describe('Acid bomb (PLAN.md §19.4)', () => { expect(isSolid('rubble')).toBe(true) }) + it('burns the Nuke’s area, and cracks every pillar in it, diagonal ones too (2026-10-26)', () => { + const s = room() + s.bombs.push({ pos: v(3, 3), timer: 1, fire: 1, placedTurn: 0, acid: true }) + const r = wait(s).state + // (4, 2) is diagonal to the bomb: no arm of a cross reaches it. + expect(r.cracked).toEqual([idx(s, v(4, 2))]) + expect(blastTiles(s, s.bombs[0]!)).toContainEqual(v(4, 3)) + expect(blastTiles(s, s.bombs[0]!)).toContainEqual(v(2, 2)) + expect(blastTiles(s, s.bombs[0]!)).toContainEqual(v(3, 1)) + }) + it('a plain bomb doesn’t crack a pillar', () => { const s = room() s.bombs.push({ pos: v(4, 3), timer: 1, fire: 2, placedTurn: 0 }) diff --git a/test/picks.test.ts b/test/picks.test.ts index 2427104..db1110e 100644 --- a/test/picks.test.ts +++ b/test/picks.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { canPick, pickTargets, resolvePick } from '../src/sim/picks' +import { isDeadly } from '../src/sim/danger' +import { canPick, pickTargets, resolvePick, tapMeaning } from '../src/sim/picks' import { board, v } from './helpers' describe('Move stands for every way of moving', () => { @@ -95,3 +96,36 @@ describe('Bomb stands for placing and throwing', () => { expect(resolvePick(s, 'teleport', v(1, 1))).toBeNull() }) }) + +describe('a tap never costs a life (user request, 2026-10-26)', () => { + it('a tap on yourself does nothing (it was Wait)', () => { + const s = board(` + ##### + #.P.# + #####`) + expect(tapMeaning(s, v(2, 1))).toBe('blocked') + }) + + it('a step in front of an enemy is deadly: a tap refuses it, Move then the tile still takes it', () => { + const s = board(` + ###### + #P.B.# + ######`) + s.enemies[0]!.plan = [v(2, 1)] + expect(tapMeaning(s, v(2, 1))).toBe('deadly') + expect(resolvePick(s, 'move', v(2, 1))).toBe('move') + expect(isDeadly(s, { id: 'move', target: v(2, 1) })).toBe(true) + expect(isDeadly(s, { id: 'wait' })).toBe(false) + }) + + it('Wait is deadly when an enemy steps onto you, unless a Shield or Immortal takes the hit', () => { + const s = board(` + ##### + #PB.# + #####`) + s.enemies[0]!.plan = [v(1, 1)] + expect(isDeadly(s, { id: 'wait' })).toBe(true) + expect(isDeadly({ ...s, charge: { kind: 'shield', left: 1 } }, { id: 'wait' })).toBe(false) + expect(isDeadly({ ...s, options: { ...s.options, immortal: true } }, { id: 'wait' })).toBe(false) + }) +}) diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index 4d78e14..501736e 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -45,6 +45,10 @@ const SHOTS = [ { name: 'lingering', act: press('KeyB', 'KeyD', 'KeyS', 'Space', 'Space') }, // Standing next to our own bomb as it goes off. { name: 'death', act: press('KeyB', 'KeyD', 'Space', 'Space', 'Space') }, + // One turn before that: waiting now would cost a life, so Wait is red... + { name: 'deadly-wait', act: press('KeyB', 'KeyD', 'Space', 'Space') }, + // Stepped down out of the blast instead: with Move picked, the step back up into it is red with a cross. + { name: 'deadly-move', act: press('KeyB', 'KeyD', 'KeyS', 'Space', 'KeyM') }, { 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=19', act: press('Space', 'Space') }, @@ -63,6 +67,8 @@ const SHOTS = [ // Carrying a Nuke (?charge=nuke): the charge button (V) puts a Nuke bomb to the right, and after // a step down the danger shows its reach; the top bar and the button show what is left. { name: 'nuke', query: 'charge=nuke', act: press('KeyV', 'KeyD', 'KeyS') }, + // Carrying Acid: its bomb's danger covers the Nuke's area (2026-10-26), with pillars diagonal to it. + { name: 'acid', query: 'charge=acid', act: press('KeyV', 'KeyS', 'KeyD') }, // Seed 107: a bomb by the gem's crate, out of the way while it goes off, then back: the gem // uncovered beside the player... (found by searching random play with the sim, 2026-10-24; the // old seed-55 moves no longer reached a gem)