explodorama / commit
Oil is slippery, and the Kenney credit leaves the menu (0.2.0 work)
CHANGELOG.md | 2 + PLAN.md | 5 +- README.md | 8 +- decisions.md | 27 ++++++ src/changelog.ts | 11 +++ src/main.ts | 3 +- src/sim/demo.ts | 42 ++++++++- src/sim/enemies.ts | 42 +++++++-- src/sim/path.ts | 17 +++- src/sim/record.ts | 2 +- src/sim/state.ts | 18 ++++ src/sim/turn.ts | 29 +++++- src/sim/types.ts | 2 + src/ui/info.ts | 12 ++- test/oil-slide.test.ts | 233 +++++++++++++++++++++++++++++++++++++++++++++++++ 15 files changed, 423 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0405ea1..43b27e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ saved before the change is dropped and the next visit starts a new run. ### Changed +- **The in-game menu no longer carries the Kenney credit** (user request, 2026-10-31); it stays in the README. - **A demo scene for rubble, and a timeline on every scene** (user request, 2026-10-31): the new `rubble` scene starts with a pillar already cracked and plays until the ceiling falls, then two turns of waiting to see the rubble (`SceneDef.maxSteps` and `tail`; the Acid scene ended when the pillar cracked, which is why the Almanac's Rubble clip never showed any). A scene now records its `moment`. A bar under the demo's caption and under each dialog's clip (`ui/timeline.ts`) fills a turn at a time, with a mark at the moment. - **Two copies of the game** (user request, 2026-10-31): the released one (built from a git tag, `/explodorama/`) and a **next** copy built from `main` (`/explodorama-next/`, deployed with `fw deploy --as explodorama-next`; tab title "EXPLODORAMA! (next)"). Each has its own saves, options, scores and last-played version (`explodorama-next.*` keys), so a rules change on `main` never drops a released run. The released game's menu has a **Try it early** link and its Changes dialog a **Coming next** teaser (read from the next copy's `next.json`); the next copy's Changes dialog lists **Not released yet**. A run finished on the next copy is marked **Early adopter** on its board and counted in one shared key (`explodorama.earlyRuns`) the released copy shows in its menu. Developer mode is separate and works on either. - **A bomb two of your actions could handle asks which** (user request, 2026-10-31): with Kick and Bomb Pass, a tap or arrow key on a bomb next to you offers Kick or Walk onto it (the Detonator's Set it off too). Bomb Pass alone now walks onto the bomb on a tap, which was refused before. @@ -24,6 +25,7 @@ saved before the change is dropped and the next visit starts a new run. ### Added +- **Oil is slippery** (user request, 2026-10-31; `SIM_VERSION` 41): landing on an oil tile after a step or a run, the player slides on in the same direction over every oil tile of the line and stops on the last one (`oilSlide` in `sim/state.ts`), short of a bomb (unless Bomb Pass) or an enemy, and on the open exit. A diagonal step, Slide and Teleport don't start one. What lies on the way is picked up; flame on the way burns the player (as with Slide), and flame where it ends does too. Every enemy but the flying Ghost and Wisp (`FLIES`) slides the same way: planning a step onto oil announces the whole slide as one planned move (`moveTiles` now returns every tile of a move; `stillPossible` re-checks a kept plan against the current oil), and the arrows show it as a doubled arrow. A slide through the player's tile catches them there. New event `oilSlid`, new demo scene `oilSlide` (also the Almanac's Oil clip), texts in the Oil entry and an enemy's info. - **Semver releases and an in-game list of changes:** the game's version is 0.1.0 (the base); the menu shows it with the rules version, and tapping it lists every release. A returning player is shown what came after the version they last played, when the opening demo ends. ## [0.1.0] - 2026-10-31 diff --git a/PLAN.md b/PLAN.md index da5b2b4..9504cc6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -126,7 +126,7 @@ on an enemy, a bomb or burning flame does nothing (never a move into it), a wall clears the auto-move flag, and any other tile the player can walk to — including the exit, the power-up and drops — sets the auto-move destination. Things the player can walk to get a **Walk there** button in their dialog. Top-bar items still explain themselves on a plain tap. A **menu** button (top right; Esc with nothing to -cancel) shows level and seed, credits Kenney, and offers **New run** behind a confirmation and +cancel) shows level and seed (no Kenney credit since 2026-10-31), and offers **New run** behind a confirmation and an **Almanac** (2026-10-22, user request; `ui/almanac.ts`): tabs for power-ups, charges and enemies, each with its sprite and the same text as "What is that?" (enemies also: the level they first appear on). @@ -625,7 +625,7 @@ plan of black Icongeek26 outline icons from Flaticon over a plain drawn board. - **Licence:** CC0, so attribution isn't required, but **the user wants Kenney credited properly**: in the README's Attribution section and in the game (a credit line with a link to - kenney.nl in the menu — *only there*, user request 2026-10-21; it used to be on the death / + kenney.nl in the menu — *only there*, user request 2026-10-21; **removed from the menu 2026-10-31, user request: the README's Attribution section is now the only credit**; it used to be on the death / game-over banner too). The pack's `License.txt` is kept in `assets/kenney/`. - **Source files:** only the sprites the game uses (or is about to use, below) are copied, unchanged @@ -1364,6 +1364,7 @@ costs a life. *Also built the same day:* the almanac's **Board** tab (crates, pillars, the five objects and rubble, with their first levels and clips) and a demo scene for each object (a bush hiding you, a bush fire, metal stopping flame, oil, spikes, a barrel). Not yet: the spikes' turn count. +*Also 2026-10-31 (`SIM_VERSION` 41, user request):* oil is **slippery**: the player and every enemy but the Ghost and Wisp slide on over a straight line of oil to its last tile (decisions.md, 2026-10-31). Water (below) would be the same sort of surface, later. ### 19.4 The Acid bomb: weakening pillars, bringing the roof down diff --git a/README.md b/README.md index 354ccb5..e32dda7 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ only `explodorama.earlyRuns` is shared. The released menu offers **Try it early* **Coming next** list; runs finished on the next copy are marked **Early adopter** on its board. Developer mode is separate and can be switched on in either. -Rules version: **40** (`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)). It goes up by one +Rules version: **41** (`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)). It goes up by one whenever a rule change would make an old run replay differently, independent of the release version (one release can span several). A run saved under another rules version is not resumed, and a seed gives the same dungeon only within one rules version. @@ -69,7 +69,7 @@ you —, Throw, Diagonal, Slide, RPG, the Detonator — a tap on your bomb —, alert, Wall / Bomb Pass, Star), every charge (Pierce, Power, Nuke, Acid, Shield, Freeze, Decoy, ICBM, Flame Pass — the ones you use, with the charge button), **the board's objects** (hiding in a bush, a bush fire -creeping on, flame stopping at a metal crate, an oil spill burning all at once, crossing spikes +creeping on, flame stopping at a metal crate, an oil spill burning all at once, sliding along a spill, crossing spikes while they're down, an oil barrel bursting and going off), **the charge swap** (carrying a Pierce, the player picks up a Nuke; the choice opens over the scene and the finger taps Take Nuke), and the exit (walking out once it opens; blasting it lets enemies out). Heart and @@ -488,7 +488,7 @@ what it does. |---|---|---| | **Bush** | 2 | Walk into it. **It hides you:** enemies chasing you head for where they last saw you instead. It hides drops lying in it too; an enemy in one shows faint, arrows and all, so nothing that can hurt you is hidden. Flame burns it, and the bushes touching it **smoulder** (glowing) and catch fire the next turn: a slow fire front. | | **Metal crate** | 3 | Grey. Like a pillar, but anywhere: nothing breaks it, nothing gets through (not a Pierce bomb's flame, not Wall Pass); a Nuke's blast goes round it. Placed only where every tile stays reachable. | -| **Oil** | 5 | A dark, glossy spill, safe to walk on — until flame touches any part of it: then **the whole spill burns at once** (and lingers a turn, like flame), setting off any bomb lying in it. | +| **Oil** | 5 | A dark, glossy spill, safe to walk on — until flame touches any part of it: then **the whole spill burns at once** (and lingers a turn, like flame), setting off any bomb lying in it. It is also **slippery**: step (or run) onto oil and you **slide straight on** over the spill, stopping on its last tile in that line (or short of a bomb or an enemy), picking up what lies on the way; if the slide ends in flame, you burn. A diagonal step doesn't slide. Every enemy but the flying Ghost and Wisp slips too, and its arrows show where the slide ends. | | **Spikes** | 8 | Up one turn, down the next. Up, they cost a life to whoever stands on them (they show in the danger overlay when up). Enemies never step on them. | | **Oil barrel** | 11 | A crate with a warning sign: it blocks like a crate. Flame **bursts** it: oil spills on it and the floor round it, and the **next turn** it explodes with a blast of 2, lighting the spill. It isn't your bomb: it doesn't count to your limit, and the Detonator can't set it off. | @@ -597,7 +597,7 @@ Art: [New Platformer Pack](https://kenney.nl/assets/new-platformer-pack) 1.1, [Kenney](https://www.kenney.nl), under [CC0](http://creativecommons.org/publicdomain/zero/1.0/) (public domain), the [Splat Pack](https://kenney.nl/assets/splat-pack) (the oil spills) and [Foliage Sprites](https://kenney.nl/assets/foliage-sprites) (the bushes). Kenney doesn't require credit, but the pack was chosen deliberately and the game -looks the way it does because of it, so it is credited here and in the game (in the menu). Thank you, Kenney! Support his work at <https://www.kenney.nl/donate>. +looks the way it does because of it, so it is credited here (it was in the game's menu until 2026-10-31). Thank you, Kenney! Support his work at <https://www.kenney.nl/donate>. The files used are copied, unchanged and under their original file names, into [`assets/kenney/`](assets/kenney/), [`assets/kenney-ui/`](assets/kenney-ui/) (keeping the pack's diff --git a/decisions.md b/decisions.md index d043af0..63b8d25 100644 --- a/decisions.md +++ b/decisions.md @@ -2303,3 +2303,30 @@ a whole sequence (Acid, then a second bomb, then waiting) was not needed once th first half. The Almanac's Rubble clip now plays the `rubble` scene, not `acid`. A timeline bar (`ui/timeline.ts`) under the demo caption and every clip shows turns played, with a mark at the scene's `moment`; chosen: normal speed, turn-by-turn fill, on both. + +### 2026-10-31 — Oil makes the player and the basic enemies slide + +User request (Pavel, 2026-10-31), details decided with him the same day: landing on oil, the player +and the basic enemies (everything but the flying Ghost and Wisp) slide on, over oil only, and stop on +the last oiled tile of the line; a slide that ends in flame kills. Chosen where it was left open: +(1) the slide goes the way the step went and is straight, it does not follow a bend in the spill; +(2) it stops short of a bomb (unless Bomb Pass) and of an enemy for the player, rather than running +into them (a slide into an enemy would be an unforeseen death; walking in still is one); flame on +the way does not stop it, as with the Slide power-up, so it burns; (3) a diagonal step, Slide and +Teleport don't start a slide (a diagonal one would need squeeze rules); (4) the action's target stays +the neighbouring tile, so validation and replays are unchanged: the slide is a consequence worked +out in `step()` (player) and planning (enemies). Auto-move (`path.ts`) plans on tiles it lands on, +so it **keeps off any step that would slide** (`slips`), else it could bounce past its destination +forever or slide onto the open exit (reviewer, 2026-10-31): a tile in the middle of a spill is not +an auto-move destination, a single tap next to you still moves and slides. Walking onto an enemy on +oil is a death although the slide carries on. Known, left: a slide over two gems uses the first +one's offer unseen (as the Slide power-up already does), and passes over raised spikes and the +Decoy without effect; (5) an enemy's slide is +announced like any move: its plan holds the tile where it stops, so the arrows and the danger +overlay stay truthful (a doubled arrow, as for a Sprinter's dash). `SIM_VERSION` 41. + +### 2026-10-31 — The Kenney credit leaves the in-game menu + +User request (Pavel, 2026-10-31). This reverses the 2026-10-21 decision that the credit lives in +the menu. Kenney's packs are CC0, so no credit is required; the README's Attribution section stays +as the credit (and still thanks Kenney and links his donation page). diff --git a/src/changelog.ts b/src/changelog.ts index 4f24886..7cbc3ec 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -48,6 +48,17 @@ export const RELEASES: Release[] = [ /** Finished since the latest release, not yet part of one. Moved into RELEASES when one is tagged. */ export const UNRELEASED: Change[] = [ + { + kind: 'changed', + icon: 'oil', + title: 'Oil is slippery', + text: [ + 'Step onto oil and you slide straight on over the spill, stopping on its last tile in that line (or short of a bomb or an enemy). You pick up what lies on the way, and flame where you end up burns you.', + 'Blobs, Seekers, Drifters, Sprinters and Hunters slip too, and their arrows show where the slide ends. Ghosts and Wisps fly over it.', + ], + clip: 'oilSlide', + date: '2026-10-31', + }, { kind: 'added', icon: 'rock', diff --git a/src/main.ts b/src/main.ts index 88d8a0f..fc99548 100644 --- a/src/main.ts +++ b/src/main.ts @@ -499,8 +499,6 @@ function showMenu(): void { `<p>${where}Seed ${state.seed}. The game saves after every move, so you can close it and come back.</p>` + (modes.length ? `<p class="mode">${modes.length > 1 ? 'Modes' : 'Mode'}: ${modes.join(', ')}.</p>` : '') + feedbackChoices() + - `<p class="credit">Art and sounds by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a>: ` + - `New Platformer Pack, UI Pack, UI Pack: RPG Expansion, Game Icons, Board Game Icons, Splat Pack and Foliage Sprites (CC0). Thank you!</p>` + // The version is the release (semver, tagged by hand); the rules version, in brackets, changes // exactly when old saves stop resuming. The version opens the list of changes. `<p class="credit">Version <a href="#" id="changes-link">${VERSION}</a> (rules ${SIM_VERSION})${commitLine()}. ` + @@ -997,6 +995,7 @@ const SCENE_ICONS: Record<SceneKind, string> = { rainbowGem: 'gem_green', immortal: 'star', dieIntoBlast: 'fireball', dieNotFar: 'fireball', dieBlockedIn: 'bomb', dieChain: 'bomb', dieEnemy: 'slime_normal_rest', dieDetonator: POWER_UP_ICONS.detonator, dieRun: POWER_UP_ICONS.run, + oilSlide: 'oil', rpg: POWER_UP_ICONS.rpg, proximity: POWER_UP_ICONS.proximity, slide: POWER_UP_ICONS.slide, icbm: CHARGE_ICONS.icbm, acid: CHARGE_ICONS.acid, rubble: 'rock', bush: 'grass_a', bushFire: 'grass_a', metal: 'block_metal', oil: 'oil', spikes: 'spikes', barrel: 'block_strong_danger', diff --git a/src/sim/demo.ts b/src/sim/demo.ts index eb2ebf7..2ac03e1 100644 --- a/src/sim/demo.ts +++ b/src/sim/demo.ts @@ -5,7 +5,7 @@ import { planIntents } from './enemies' import { Rng } from './rng' import { autoMoveAction, playerPath, safeStep } from './path' import { membersOf, resolvePick, tapMeaning } from './picks' -import { exitOpen, idx, inBounds, noPowers, START_LIVES, START_STATS } from './state' +import { exitOpen, idx, inBounds, noPowers, playerCanEnter, START_LIVES, START_STATS } from './state' import { step } from './turn' import { DEFAULT_OPTIONS, @@ -42,7 +42,7 @@ export type SceneKind = | 'pierce' | 'power' | 'nuke' | 'acid' | 'rubble' | 'shield' | 'freeze' | 'decoy' | 'chargeSwap' | 'exit' | 'doorBlast' | 'lastEnemy' | 'crate' | 'pillar' | 'rainbowGem' | 'immortal' - | 'bush' | 'bushFire' | 'metal' | 'oil' | 'spikes' | 'barrel' + | 'bush' | 'bushFire' | 'metal' | 'oil' | 'oilSlide' | 'spikes' | 'barrel' | 'dieIntoBlast' | 'dieNotFar' | 'dieBlockedIn' | 'dieChain' | 'dieEnemy' | 'dieDetonator' | 'dieRun' /** @@ -519,6 +519,15 @@ export const SCENES: Record<SceneKind, SceneDef> = { prepare: (s, rng) => placeObjects(s, rng, 'oil', 2, 4), shows: (t) => t.next.status === 'playing' && t.events.some((e) => e.type === 'fireSpread' && e.tiles.length >= 2), }, + oilSlide: { + caption: 'Oil is slippery: step onto a spill and you slide to its far end. Most enemies slip on it too.', + enemies: ['blob', 'drifter'], + prepare: (s, rng) => placeOilLine(s, rng, 4), + // From the third turn on, step onto the spill along its length when the way is there. + steer: (s) => (s.turn >= 2 ? playOf(SCENES.oilSlide, s, slipMove(s)) : null), + shows: (t) => t.events.some((e) => e.type === 'oilSlid'), + tail: 1, + }, spikes: { caption: 'Spikes: up one turn, down the next. Cross while they are down; enemies never step on them.', enemies: ['blob'], @@ -806,6 +815,35 @@ function placeObjects(s: GameState, rng: Rng, kind: 'bush' | 'oil' | 'spikes' | } } +/** + * An oil spill in a straight line of `len` tiles, 2 steps from the player at its nearer end (the + * tiles are made floor); or, with no room, a plain clump. A slide is only seen along a spill. + */ +function placeOilLine(s: GameState, rng: Rng, len: number): void { + const lines = tilesAt(s, 2).flatMap((from) => + DIR_LIST.map((d) => DIRS[d]).flatMap((d) => { + const line = Array.from({ length: len }, (_, n) => ({ x: from.x + n * d.x, y: from.y + n * d.y })) + const ok = line.every((p) => inBounds(s, p) && s.cells[idx(s, p)] !== 'wall' && !at(p, s.player) && !s.enemies.some((e) => at(e.pos, p))) + return ok ? [line] : [] + }), + ) + if (!lines.length) return placeObjects(s, rng, 'oil', 2, len) + for (const p of lines[rng.int(lines.length)]!) { + s.cells[idx(s, p)] = 'floor' + s.oil.push(idx(s, p)) + } +} + +/** A step onto an oil tile that has oil behind it, the way the step goes: it slides on. Null if none. */ +function slipMove(s: GameState): Action | null { + for (const d of DIR_LIST) { + const first = { x: s.player.x + DIRS[d].x, y: s.player.y + DIRS[d].y } + const second = { x: first.x + DIRS[d].x, y: first.y + DIRS[d].y } + if (playerCanEnter(s, first) && s.oil.includes(idx(s, first)) && inBounds(s, second) && s.oil.includes(idx(s, second))) return { id: 'move', target: first } + } + return null +} + /** Put a charge `n` steps from the player. */ function dropNear(s: GameState, rng: Rng, kind: ChargeId, n = 2): void { const near = tilesAt(s, n) diff --git a/src/sim/enemies.ts b/src/sim/enemies.ts index 7130b8d..0fc654f 100644 --- a/src/sim/enemies.ts +++ b/src/sim/enemies.ts @@ -1,10 +1,13 @@ import { Rng } from './rng' -import { add, bombAt, cellAt, eq, idx } from './state' +import { add, bombAt, cellAt, eq, idx, oilSlide } 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' /** 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 Wisps fly: they don't slip on oil. Every other enemy slides over it (user request, 2026-10-31). */ +export const FLIES: 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. @@ -56,12 +59,27 @@ export function dirBetween(from: Vec, to: Vec): Dir | 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. + * tile for a step, every tile on the line for a Sprinter's dash or a slide over oil. */ 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] + const dir = dirBetween(from, to) + if (!dir) return [] + return Array.from({ length: steps }, (_, i) => ({ x: from.x + DIRS[dir].x * (i + 1), y: from.y + DIRS[dir].y * (i + 1) })) +} + +/** + * Where a move of `n` tiles from `from` heading `dir` ends for `kind`: those tiles, then on over + * oil if it lands on some (not for a flier). Null if a tile on the way can't be entered. + */ +function moveEnd(s: GameState, kind: EnemyKind, from: Vec, dir: Dir, n: number): Vec | null { + let land = from + for (let i = 0; i < n; i++) { + land = add(land, DIRS[dir]) + if (!enemyCanEnter(s, land, kind)) return null + } + if (FLIES.has(kind)) return land + return oilSlide(s, land, DIRS[dir], (q) => enemyCanEnter(s, q, kind)).at(-1) ?? land } /** Whether a kept planned move is still one this enemy can make on the board as it is now. */ @@ -69,9 +87,13 @@ function stillPossible(s: GameState, kind: EnemyKind, from: Vec, to: Vec): boole 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)) + if (dx && dy) return false + const dir = dirBetween(from, to)! + // A step (a Sprinter's dash too), with the slide over oil it may end in. + return (kind === 'sprinter' ? [1, 2] : [1]).some((n) => { + const end = moveEnd(s, kind, from, dir, n) + return !!end && eq(end, to) + }) } /** How many moves ahead every enemy plans, and shows (PLAN.md §4). */ @@ -201,8 +223,10 @@ export function planIntents(s: GameState): void { rest = false continue } - const st = PLANNERS[e.kind](s, w, rng) - if (!st) break + const planned = PLANNERS[e.kind](s, w, rng) + if (!planned) break + // Landing on oil, it slides on to the last oiled tile of the line. + const st = { dir: planned.dir, to: FLIES.has(e.kind) ? planned.to : (oilSlide(s, planned.to, DIRS[planned.dir], (q) => enemyCanEnter(s, q, e.kind)).at(-1) ?? planned.to) } e.plan.push(st.to) w.pos = st.to w.dir = st.dir diff --git a/src/sim/path.ts b/src/sim/path.ts index 7bd32f3..99dc006 100644 --- a/src/sim/path.ts +++ b/src/sim/path.ts @@ -1,12 +1,23 @@ import { diagonalAllowed, isAvailable, isValidTarget } from './actions' import type { Danger } from './danger' import { enemyAt, moveTiles } from './enemies' -import { add, eq, exitOpen, flameSafe, idx, playerCanPass } from './state' +import { add, eq, exitOpen, flameSafe, idx, oilSlide, playerCanPass } from './state' import { DIRS, DIR_LIST, type Action, type GameState, type Vec } from './types' /** The four diagonal steps, for a walk with Diagonal. */ const DIAGONALS: readonly Vec[] = [{ x: -1, y: -1 }, { x: 1, y: -1 }, { x: -1, y: 1 }, { x: 1, y: 1 }] +/** + * Whether a step from `from` to its neighbour `q` (or a run landing on `q`) would start an oil + * slide: the player would not stay on `q`. Auto-move keeps off such steps (reviewer, 2026-10-31): + * it plans on tiles it lands on, and a slide can carry the player past the destination, back and + * forth for ever, or onto the open exit. A tap on a single tile away still moves, and slides. + */ +export function slips(s: GameState, from: Vec, q: Vec): boolean { + const d = { x: Math.sign(q.x - from.x), y: Math.sign(q.y - from.y) } + return !(d.x && d.y) && oilSlide(s, q, d, (p) => playerCanPass(s, p)).length > 0 +} + /** * The shortest walk for the player to `to`, for auto-move, over tiles the player can pass (free * floor, plus crates or bombs with Wall or Bomb Pass). The result leaves out the player's own @@ -30,7 +41,7 @@ export function playerPath(s: GameState, to: Vec): Vec[] | null { for (const d of steps) { if (d.x && d.y && !diagonalAllowed(s, p, d)) continue const q = add(p, d) - if (prev.has(key(q)) || !playerCanPass(s, q) || (avoidExit && eq(q, s.exit))) continue + if (prev.has(key(q)) || !playerCanPass(s, q) || (avoidExit && eq(q, s.exit)) || slips(s, p, q)) continue prev.set(key(q), p) if (eq(q, to)) { const path = [q] @@ -62,7 +73,7 @@ export function autoMoveAction(s: GameState, to: Vec, safe: (p: Vec) => boolean) const options: { action: Action; left: number }[] = [] const [next, after] = [path?.[0], path?.[1]] const straight = next && after && after.x - next.x === next.x - s.player.x && after.y - next.y === next.y - s.player.y - if (straight && isAvailable(s, 'run') && isValidTarget(s, 'run', after) && safe(next) && safe(after)) { + if (straight && isAvailable(s, 'run') && isValidTarget(s, 'run', after) && safe(next) && safe(after) && !slips(s, next, after)) { options.push({ action: { id: 'run', target: after }, left: path!.length - 2 }) } if (step) options.push({ action: step, left: path!.length - 1 }) diff --git a/src/sim/record.ts b/src/sim/record.ts index fbf137e..8ea17a9 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 = 40 +export const SIM_VERSION = 41 /** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */ export interface RunRecord { diff --git a/src/sim/state.ts b/src/sim/state.ts index 82f72bf..a78e7fe 100644 --- a/src/sim/state.ts +++ b/src/sim/state.ts @@ -155,6 +155,24 @@ export function bombAt(s: GameState, p: Vec): Bomb | undefined { return s.bombs.find((b) => eq(b.pos, p)) } +/** + * Oil is slippery (user request, 2026-10-31). Whoever lands on an oil tile heading `d` keeps + * going over oil, and stops on the last oiled tile of the line. Returns the tiles it slides + * over after `from`, the last being where it stops; none if `from` isn't oil, or the next tile + * isn't oil or `canEnter` refuses it (a bomb, an enemy...). + */ +export function oilSlide(s: GameState, from: Vec, d: Vec, canEnter: (p: Vec) => boolean): Vec[] { + const tiles: Vec[] = [] + let p = from + while (s.oil.includes(idx(s, p))) { + const q = add(p, d) + if (!inBounds(s, q) || !s.oil.includes(idx(s, q)) || !canEnter(q)) break + tiles.push(q) + p = q + } + return tiles +} + /** Nothing in the way: floor, and no bomb on it. */ export function isFree(s: GameState, p: Vec): boolean { return cellAt(s, p) === 'floor' && !bombAt(s, p) diff --git a/src/sim/turn.ts b/src/sim/turn.ts index b66f7af..a8ebd50 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -3,7 +3,7 @@ import { holds, sellOffered, spendCharge, swapOffered, takeCharge } from './char import { enemyAt, moveEnemies, planIntents, rollDrops } from './enemies' import { enemyKinds, LAST_LEVEL } from './levelgen' import { collapse, detonate, proximityAlert, spikesUp, tickBombs, walledIn } from './explosions' -import { add, bombAt, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, nearestFloor, CLEAR_BONUS, PAR_BONUS } from './state' +import { add, bombAt, carryOf, cellAt, cloneState, createLevel, eq, exitOpen, idx, nearestFloor, oilSlide, playerCanPass, CLEAR_BONUS, PAR_BONUS } from './state' import { stream } from './rng' import { buy, openShop, reroll, shopAfter } from './shop' import { CHARGE_IDS, DIRS, DIR_LIST, isCharge, type Action, type Death, type GameEvent, type GameState, type OfferKind, type Powers, type PowerUpKind, type Stats, type Vec } from './types' @@ -88,7 +88,7 @@ export function step(prev: GameState, action: Action): StepResult { } const from = s.player // A slide passes over every tile before where it stops (worked out before the player moves). - const slid = action.id === 'slide' ? slidePassed(s, action.target!) : [] + const slid: Vec[] = action.id === 'slide' ? slidePassed(s, action.target!) : [] def.apply(s, action.target, events) // A run passes through the tile in between: anything there is picked up, or run into. const passed: Vec | null = action.id === 'run' ? add(from, { x: (s.player.x - from.x) / 2, y: (s.player.y - from.y) / 2 }) : null @@ -96,6 +96,29 @@ export function step(prev: GameState, action: Action): StepResult { // A slide picks up whatever it passes over. for (const p of slid) pickUp(s, p, events) pickUp(s, s.player, events) + // Landing on oil after a step or a run, the player slips on to the last oiled tile of the line + // (user request, 2026-10-31), picking up what lies on the way. Not on a diagonal step. + const oiled: Vec[] = [] + // Where the step landed, before any slide: an enemy standing there is walked into all the same. + let landedOn: Vec | null = null + if (action.id === 'move' || action.id === 'run') { + const d = { x: Math.sign(s.player.x - from.x), y: Math.sign(s.player.y - from.y) } + // Stops before a bomb (unless Bomb Pass) and an enemy; flame doesn't stop it, as with Slide. + const path = oilSlide(s, s.player, d, (q) => playerCanPass(s, q) && !enemyAt(s, q)) + // It stops on the open exit (spilled oil can reach it), as a Slide does. + const atExit = exitOpen(s) ? path.findIndex((q) => eq(q, s.exit)) : -1 + if (atExit >= 0) path.length = atExit + 1 + if (path.length) { + const landed = s.player + landedOn = landed + s.player = path[path.length - 1]! + events.push({ type: 'oilSlid', from: landed, to: s.player }) + oiled.push(...path.slice(0, -1)) + for (const p of oiled) pickUp(s, p, events) + pickUp(s, s.player, events) + slid.push(landed, ...oiled) + } + } if (exitOpen(s) && eq(s.player, s.exit)) { const parBonus = s.turn + 1 <= s.par ? PAR_BONUS : 0 s.coins += CLEAR_BONUS + parBonus @@ -140,7 +163,7 @@ export function step(prev: GameState, action: Action): StepResult { } // Contact, first check: the player walked (or ran) into an enemy, or swapped places with one. - const walkedInto = enemyAt(s, s.player) ?? (passed ? enemyAt(s, passed) : undefined) + const walkedInto = enemyAt(s, s.player) ?? (passed ? enemyAt(s, passed) : undefined) ?? (landedOn ? enemyAt(s, landedOn) : undefined) if (walkedInto) hurt({ cause: 'walkedIntoEnemy', enemy: walkedInto.kind, turn: s.turn + 1 }) // A slide over last turn's flame burns (it doesn't stop for it: user's choice, 2026-10-24). if (s.status === 'playing' && slid.some((p) => s.burning.includes(idx(s, p)))) hurt({ cause: 'lingering', turn: s.turn + 1 }) diff --git a/src/sim/types.ts b/src/sim/types.ts index 723fddc..af714fa 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -362,6 +362,8 @@ export interface Death { /** What `step()` reports happened, in order, for the renderer to play back. */ export type GameEvent = | { type: 'moved'; from: Vec; to: Vec } + /** The player slid on over oil after a move: from the tile they landed on to where they stopped. */ + | { type: 'oilSlid'; from: Vec; to: Vec } | { type: 'waited' } | { type: 'bombPlaced'; pos: Vec; timer: number } | { type: 'bombTicked'; pos: Vec; timer: number } diff --git a/src/ui/info.ts b/src/ui/info.ts index 93d7d5f..177b183 100644 --- a/src/ui/info.ts +++ b/src/ui/info.ts @@ -6,7 +6,7 @@ import { BARREL_FIRE, COLLAPSE_TURNS, spikesUp } from '../sim/explosions' import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS, STAT_MAX, STAT_STEP } from '../sim/turn' import { SHOP_EVERY } from '../sim/shop' import { isCharge, type ActionId, type ChargeId, type Death, type Dir, type EnemyKind, type GameState, type OfferKind, type Powers, type PowerUpKind, type Vec } from '../sim/types' -import { dirBetween, RESTS } from '../sim/enemies' +import { dirBetween, FLIES, 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' @@ -288,8 +288,11 @@ export const BOARD_TEXT: Record<BoardThing, { icon: string; name: string; text: oil: { icon: 'oil', name: 'Oil', - text: ['Safe to walk on, until flame touches any part of the spill: then the whole spill burns at once, and sets off any bomb lying in it.'], - clip: 'oil', + text: [ + 'Safe to walk on, until flame touches any part of the spill: then the whole spill burns at once, and sets off any bomb lying in it.', + 'And slippery: step (or run) onto oil and you slide straight on over the spill, stopping on its last tile in that line, or short of a bomb or an enemy. You pick up what lies on the way, and if you end up in flame, it burns you. Most enemies slip too (not the flying Ghost and Wisp), and their arrows show where the slide ends.', + ], + clip: 'oilSlide', from: OBJECTS_FROM.oil, }, spikes: { @@ -342,8 +345,9 @@ 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${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.` + ? `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 or a slide over oil)' : ')') : ''}. 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).', + FLIES.has(e.kind) ? 'It flies, so oil doesn’t make it slip.' : 'It slips on oil: landing on a spill, it slides on to the last oiled tile in that line (shown as a doubled arrow).', 'Flame kills it, including flame still burning from last turn. Touching it costs you a life.', ], } diff --git a/test/oil-slide.test.ts b/test/oil-slide.test.ts new file mode 100644 index 0000000..6d64cf3 --- /dev/null +++ b/test/oil-slide.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from 'vitest' +import { planIntents } from '../src/sim/enemies' +import { autoMoveAction, safeStep } from '../src/sim/path' +import { predictDanger } from '../src/sim/danger' +import { step } from '../src/sim/turn' +import { board, v } from './helpers' + +describe('oil is slippery (user request, 2026-10-31)', () => { + it('a step onto oil slides the player to the last oiled tile of the line', () => { + const s = board(` + ######### + #P.~~~.## + #########`) + const r = step(s, { id: 'move', target: v(2, 1) }) + expect(r.state.player).toEqual(v(2, 1)) + const t = step(r.state, { id: 'move', target: v(3, 1) }) + expect(t.state.player).toEqual(v(5, 1)) + expect(t.events).toContainEqual({ type: 'oilSlid', from: v(3, 1), to: v(5, 1) }) + }) + + it('stops on the last oiled tile, not at the wall or the next plain tile', () => { + const s = board(` + ####### + #P~~..# + #######`) + expect(step(s, { id: 'move', target: v(2, 1) }).state.player).toEqual(v(3, 1)) + }) + + it('goes straight on: it does not follow a bend in the spill', () => { + const s = board(` + ##### + #P~.# + #.~.# + #####`) + expect(step(s, { id: 'move', target: v(2, 1) }).state.player).toEqual(v(2, 1)) + }) + + it('leaving oil, or stepping on a lone oil tile, does not slide', () => { + const s = board(` + ##### + #~P.# + #####`) + expect(step(s, { id: 'move', target: v(3, 1) }).state.player).toEqual(v(3, 1)) + expect(step(s, { id: 'move', target: v(1, 1) }).state.player).toEqual(v(1, 1)) + }) + + it('stops before a bomb, and before an enemy', () => { + const bomb = board(` + ####### + #P~~1.# + #######`) + bomb.bombs[0]!.timer = 5 + expect(step(bomb, { id: 'move', target: v(2, 1) }).state.player).toEqual(v(3, 1)) + const foe = board(` + ####### + #P~~B.# + #######`) + expect(step(foe, { id: 'move', target: v(2, 1) }).state.player).toEqual(v(3, 1)) + }) + + it('a run that lands on oil slides on', () => { + const s = board(` + ########## + #P.~~~~..# + ##########`) + s.powers.run = true + expect(step(s, { id: 'run', target: v(3, 1) }).state.player).toEqual(v(6, 1)) + }) + + it('picks up what lies on the oil it slides over', () => { + const s = board(` + ######### + #P~~~~..# + #########`) + s.drops.push({ pos: v(3, 1), kind: 'coins', amount: 1 }) + const r = step(s, { id: 'move', target: v(2, 1) }) + expect(r.state.player).toEqual(v(5, 1)) + expect(r.state.coins).toBe(1) + }) + + it('a slide that ends in flame kills', () => { + const s = board(` + ######### + #P~~~...# + #########`) + s.bombs.push({ pos: v(5, 1), timer: 1, fire: 2, placedTurn: 0 }) + // The bomb's flame (and the oil it lights) is where the slide ends. + const r = step(s, { id: 'move', target: v(2, 1) }) + expect(r.state.status).toBe('dead') + }) + + it('a basic enemy planning a step onto oil announces the whole slide', () => { + const s = board(` + ########## + #D~~~..P## + ##########`) + planIntents(s) + expect(s.enemies[0]!.plan[0]).toEqual(v(4, 1)) + }) + + it('moves along the whole announced slide', () => { + const s = board(` + ########## + #D~~~..P## + ##########`) + s.enemies[0]!.plan = [v(4, 1)] + const r = step(s, { id: 'wait' }) + expect(r.state.enemies[0]!.pos).toEqual(v(4, 1)) + }) + + it('a Ghost or Wisp flies over oil without slipping', () => { + const s = board(` + ########## + #G~~~..P## + ##########`) + s.enemies[0]!.plan = [v(2, 1)] + const r = step(s, { id: 'wait' }) + expect(r.state.enemies[0]!.pos).toEqual(v(2, 1)) + }) + + it('an enemy that slides through the player catches them there', () => { + const s = board(` + ########## + #D~~~~...# + #####P#### + ##########`) + s.player = v(3, 1) + s.enemies[0]!.plan = [v(5, 1)] + const r = step(s, { id: 'wait' }) + expect(r.state.status).toBe('dead') + expect(r.state.enemies[0]!.pos).toEqual(v(3, 1)) + }) + + it('a plan that no longer matches the oil is dropped and planned again', () => { + const s = board(` + ########## + #D~~~..P## + ##########`) + s.enemies[0]!.plan = [v(4, 1)] + s.oil = [] + planIntents(s) + expect(s.enemies[0]!.plan[0]).not.toEqual(v(4, 1)) + }) + + it('walking onto an enemy that stands on oil is a death, even though the slide carries on', () => { + const s = board(` + ####### + #PB~~.# + #######`) + s.oil.push(2 + 7) + const r = step(s, { id: 'move', target: v(2, 1) }) + expect(r.state.status).toBe('dead') + expect(r.state.death?.cause).toBe('walkedIntoEnemy') + }) + + it('a diagonal step onto oil does not slide', () => { + const s = board(` + ##### + #P..# + #.~~# + #####`) + s.powers.diagonal = true + expect(step(s, { id: 'diagonal', target: v(2, 2) }).state.player).toEqual(v(2, 2)) + }) + + it('with Bomb Pass the slide goes over a bomb', () => { + const s = board(` + ####### + #P~~5.# + #######`) + s.oil.push(4 + 7) + s.powers.bombPass = true + expect(step(s, { id: 'move', target: v(2, 1) }).state.player).toEqual(v(4, 1)) + }) + + it('stops on the open exit, which clears the level', () => { + const s = board(` + ####### + #P~~E.# + #######`) + s.oil.push(4 + 7) + const r = step(s, { id: 'move', target: v(2, 1) }) + expect(r.state.player).toEqual(v(4, 1)) + expect(r.state.status).toBe('cleared') + }) + + it('lingering flame on the way burns the player', () => { + const s = board(` + ####### + #P~~~.# + #######`) + s.burning = [3 + 7] + expect(step(s, { id: 'move', target: v(2, 1) }).state.status).toBe('dead') + }) + + it('a Sprinter’s planned dash that lands on oil slides on, and the plan is kept while valid', () => { + const s = board(` + ########### + #R.~~~..P## + ###########`) + const e = s.enemies[0]! + e.plan = [v(1, 1), v(5, 1)] + planIntents(s) + expect(e.plan.slice(0, 2)).toEqual([v(1, 1), v(5, 1)]) + }) + + it('auto-move keeps off a step that would slide, so it never bounces or slides onto the exit', () => { + const s = board(` + ######### + #P.~~~~.# + #########`) + const danger = predictDanger(s) + expect(autoMoveAction(s, v(4, 1), (p) => safeStep(s, danger, p))).toBeNull() + const e = board(` + ########## + #P~~E...## + ####.##### + ##########`) + e.oil.push(4 + 10) + const act = autoMoveAction(e, v(4, 2), () => true) + expect(act).toBeNull() + }) + + it('a Ghost’s plan is not slid when planned', () => { + const s = board(` + ########## + #G~~~..P## + ##########`) + s.enemies[0]!.dir = 'right' + planIntents(s) + for (const p of s.enemies[0]!.plan) expect(p.x).toBeLessThanOrEqual(2) + }) +})