explodorama / commit
Demo: the ways to die, and the exit
CHANGELOG.md | 5 +- PLAN.md | 12 +++- README.md | 8 ++- decisions.md | 25 ++++++++ docs/screenshots/almanac-charges.png | Bin 369698 -> 369609 bytes docs/screenshots/almanac.png | Bin 429320 -> 429479 bytes docs/screenshots/auto-move.png | Bin 244640 -> 244588 bytes docs/screenshots/bomb-picked.png | Bin 243547 -> 243597 bytes docs/screenshots/charges.png | Bin 256659 -> 256654 bytes docs/screenshots/desktop.png | Bin 583119 -> 583108 bytes docs/screenshots/dev.png | Bin 245790 -> 245842 bytes docs/screenshots/gem.png | Bin 244258 -> 244285 bytes docs/screenshots/info-mode.png | Bin 242881 -> 242990 bytes docs/screenshots/lingering.png | Bin 248622 -> 248488 bytes docs/screenshots/menu.png | Bin 170738 -> 170784 bytes docs/screenshots/scrolled.png | Bin 246763 -> 246872 bytes docs/screenshots/small.png | Bin 186730 -> 186731 bytes src/main.ts | 20 ++++-- src/sim/demo.ts | 117 +++++++++++++++++++++++++++++++++-- src/style.css | 3 +- test/demo.test.ts | 10 +-- 21 files changed, 177 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b403a1f..6856adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,8 +57,9 @@ saved before the change is dropped and the next visit starts a new run. Board Game Icons (all CC0). Power-ups that improve something get a green modifier badge. - **A demo before the game:** scenes made up on the spot from the real rules, showing every feature on the board — bombs, watching the enemies' arrows, escapes, chains, lingering flame, - the enemy types, drops, the gem, each power-up and each charge — each with a caption and an - icon, until you tap. + the enemy types, drops, the gem, each power-up and each charge, the exit — alternating with the + ways to die (into a blast, not far enough, blocked in, a chain, an enemy, and a couple of funny + ones), each with a caption and an icon, until you tap. - **Installable** as an app (Chrome, Edge, Android, iPhone / iPad), and playable offline once loaded. - **Deployment** to GitLab Pages on every push to the default branch, after the tests and the diff --git a/PLAN.md b/PLAN.md index 051c259..563bb3b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -601,8 +601,16 @@ lingering flame, the three enemy types, drops, the gem (uncovered and taken; its 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, -so they have none. All 27 are found on every seed tried, the slowest (Pierce, given extra -crates) in under 0.4 s; each caption has the thing's icon. The UI (`main.ts`: `startDemo`, `demoTick`) plays a +so they have none. *Since 2026-10-23 (user request)* also the exit — **exit** (it opens, the +player walks in and the scene ends there; once open, the random play mostly heads for it) and +**doorBlast** (flame on the uncovered exit lets 3 enemies out) — and **the ways to die**, each a +scene whose moment is the death: into a blast (from a safe tile onto a burning one), not far +enough (moved but stayed in reach; Fire 3), blocked in (no step out of danger), chain (caught +only by a bomb that wasn't due), an enemy, and two funny ones: the Detonator next to you, a Run +into an enemy. The random play keeps refusing deadly moves, except the one that is the scene's +death. Living and dying scenes take turns, each queue shuffled. 36 kinds, all found on every +seed tried, the slowest under 0.5 s; each caption has the thing's icon. The "Ouch!" and +level-cleared banners are hidden in the demo. The UI (`main.ts`: `startDemo`, `demoTick`) plays a turn every 0.6 s through the same animation code as play, hides the top bar and buttons, and fits the room above the caption panel. Every caption has an icon beside it: for a power-up or charge, the same icon as on the board, in the top bar and the almanac. Ideas: more kinds (the exit, a gem's choice, Kick and diff --git a/README.md b/README.md index 5dbe1d6..3af9ad0 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,12 @@ board: bombs and their countdown, watching the enemies' arrows to bomb where the narrow escape, chain reactions, lingering flame, the three enemy types, drops, the gem, every power-up you can see in action (Bomb +1, Fire +1, Fuse +1, Run, Kick, Throw, Teleport, Diagonal, the Detonator, Wall / Bomb / Flame Pass, Star) and every charge (Pierce, Power, Nuke, Shield, -Freeze, Decoy). Heart and Coin bag only change numbers, so they have no scene; neither do the -exit or the shop. The scenes come in a shuffled order, and every time they're different. It is +Freeze, Decoy), and the exit (walking out once it opens; blasting it lets enemies out). Heart and +Coin bag only change numbers, so they have no scene; nor does the shop. Every other scene shows +**a way to die**: walking into a blast, not getting far enough, blocking yourself in with your +own bomb, one bomb setting off another, touching an enemy, and a couple of funny ones (the +Detonator next to you, a Run into an enemy). Living and dying scenes take turns, each in a +shuffled order, and every time they're different. It is view-only; **tap anywhere (or press any key) to play**, and that tap does nothing else. A test URL (`?seed` and the like) skips it; `?demo` shows it anyway. diff --git a/decisions.md b/decisions.md index acb47a5..846863a 100644 --- a/decisions.md +++ b/decisions.md @@ -867,3 +867,28 @@ out, so there is always something to drop. A search over 40 seeds of every demo such drops before; after, only 2 Freezes dropped in the turn the old freeze ran out (rolled once it had, so not wasted). Tests: over 2,000 rolls each, none of the three drops while one lies there or works; Pierce still does. `SIM_VERSION` 16. + +### 2026-10-23 — The demo shows how you die, and the exit + +User request: "how does the player die?" — scenes for the ways to die (walking into flame, +blocking yourself in, not getting far enough, a bomb setting off another, an enemy, "etc."), +alternating living and dying scenes, "some funny ones maybe"; and the exit, with scenes ending +naturally when the player walks out through the open door. + +- **Seven death scenes**, each ending on the death: into a blast (stepped from a safe tile onto + one that burns that turn), not far enough (was in reach, moved, still in reach; the room gets + Fire 3 so it happens), blocked in (every step is about to burn), chain (caught only by a bomb + that wasn't due, so the chain set it off), an enemy, and the funny two: the Detonator set off + while standing next to the bomb, and a Run straight into an enemy. Walking into lingering + flame isn't possible (the game refuses that step), so "walking into flame" is the blast one. + The random play still refuses deadly moves except the one that is the scene's death. +- **The exit**: one scene where it opens and the player walks out — the scene ends there, with + the jump into the doorway (once open, the random play heads for it four times in five, or it + is rarely found) — and one where flame on the uncovered exit lets three enemies out. I read + "demos end naturally when the player walks into an open door" as that exit scene; other living + scenes still end on their own moment. +- **Living and dying scenes take turns**, each from its own shuffled queue, so the 29 living + ones aren't spread thin between deaths. The "Ouch!" and level-cleared banners are hidden in + the demo. Tests: every kind found on 6 seeds, the player alive throughout and dead at the end + of a death scene, and `showsAtEnd` confirms the moment. In a browser: 12 scenes in 70 s, + alternating, no errors. diff --git a/docs/screenshots/almanac-charges.png b/docs/screenshots/almanac-charges.png index 7e35bd5..4d03396 100644 Binary files a/docs/screenshots/almanac-charges.png and b/docs/screenshots/almanac-charges.png differ diff --git a/docs/screenshots/almanac.png b/docs/screenshots/almanac.png index f91eb05..7b2e903 100644 Binary files a/docs/screenshots/almanac.png and b/docs/screenshots/almanac.png differ diff --git a/docs/screenshots/auto-move.png b/docs/screenshots/auto-move.png index 37829e5..bf11d50 100644 Binary files a/docs/screenshots/auto-move.png and b/docs/screenshots/auto-move.png differ diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png index 319006e..8146a75 100644 Binary files a/docs/screenshots/bomb-picked.png and b/docs/screenshots/bomb-picked.png differ diff --git a/docs/screenshots/charges.png b/docs/screenshots/charges.png index 0475bc3..4b4c9d6 100644 Binary files a/docs/screenshots/charges.png and b/docs/screenshots/charges.png differ diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png index a7c0728..83428b3 100644 Binary files a/docs/screenshots/desktop.png and b/docs/screenshots/desktop.png differ diff --git a/docs/screenshots/dev.png b/docs/screenshots/dev.png index abff8e7..4d41bde 100644 Binary files a/docs/screenshots/dev.png and b/docs/screenshots/dev.png differ diff --git a/docs/screenshots/gem.png b/docs/screenshots/gem.png index f428aec..5d3c9e6 100644 Binary files a/docs/screenshots/gem.png and b/docs/screenshots/gem.png differ diff --git a/docs/screenshots/info-mode.png b/docs/screenshots/info-mode.png index 3ba678f..c8c2e8a 100644 Binary files a/docs/screenshots/info-mode.png and b/docs/screenshots/info-mode.png differ diff --git a/docs/screenshots/lingering.png b/docs/screenshots/lingering.png index c1a0e4b..43d66c1 100644 Binary files a/docs/screenshots/lingering.png and b/docs/screenshots/lingering.png differ diff --git a/docs/screenshots/menu.png b/docs/screenshots/menu.png index 99d0710..008af24 100644 Binary files a/docs/screenshots/menu.png and b/docs/screenshots/menu.png differ diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png index 19ebf69..177a4b7 100644 Binary files a/docs/screenshots/scrolled.png and b/docs/screenshots/scrolled.png differ diff --git a/docs/screenshots/small.png b/docs/screenshots/small.png index 6b4d434..6ae57e2 100644 Binary files a/docs/screenshots/small.png and b/docs/screenshots/small.png differ diff --git a/src/main.ts b/src/main.ts index fce8c1d..4561117 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,7 +30,7 @@ import { dialogOpen, openDialog, type DialogButton } from './ui/dialog' import { actionInfo, chargeInfo, hudInfo, modeLines, tileInfo, type Info } from './ui/info' import { openOffer } from './ui/offer' import { reportError } from './devlog' -import { generateScene, SCENE_KINDS, SCENES, type Scene, type SceneKind } from './sim/demo' +import { DYING_KINDS, generateScene, LIVING_KINDS, SCENE_KINDS, SCENES, type Scene, type SceneKind } from './sim/demo' import { spriteUrl } from './render/sprites' import playIcon from '../assets/kenney-ui/Extra/icon_play_light.png' import newRunIcon from '../assets/kenney-ui/Extra/icon_repeat_light.png' @@ -531,7 +531,10 @@ const DEMO_LEAD_SECONDS = 0.8 let demo: { scene: Scene; next: number; at: number; real: GameState } | null = null // The scenes' seeds: from `?seed` for a repeatable demo, otherwise random. let demoSeed = Number(params.get('seed')) || randomSeed() -let demoKinds: SceneKind[] = [] +// Two shuffled queues, taken in turn: a scene where the player lives, then one where they die +// (user request, 2026-10-23: alternate them). In their usual order with `?seed`, for a test URL. +const demoQueues: Record<'living' | 'dying', SceneKind[]> = { living: [], dying: [] } +let demoDying = false const demoPanel = document.getElementById('demo')! const demoCaption = document.getElementById('demo-caption')! /** @@ -547,15 +550,20 @@ const SCENE_ICONS: Record<SceneKind, string> = { bombPass: POWER_UP_ICONS.bombPass, flamePass: POWER_UP_ICONS.flamePass, star: POWER_UP_ICONS.star, pierce: CHARGE_ICONS.pierce, power: CHARGE_ICONS.power, nuke: CHARGE_ICONS.nuke, shield: CHARGE_ICONS.shield, freeze: CHARGE_ICONS.freeze, decoy: CHARGE_ICONS.decoy, + exit: 'door_open_top', doorBlast: 'door_closed_top', + dieIntoBlast: 'fireball', dieNotFar: 'fireball', dieBlockedIn: 'bomb', dieChain: 'bomb', dieEnemy: 'slime_normal_rest', + dieDetonator: POWER_UP_ICONS.detonator, dieRun: POWER_UP_ICONS.run, } -/** The next scene, cycling through every kind in a shuffled order. */ +/** The next scene: living and dying ones in turn, each cycling through its kinds in a shuffled order. */ function nextScene(): void { if (!demo) return for (let tries = 0; tries < SCENE_KINDS.length; tries++) { - // Shuffled; in their usual order with `?seed`, so a test URL shows the same demo every time. - if (!demoKinds.length) demoKinds = params.has('seed') ? [...SCENE_KINDS] : [...SCENE_KINDS].sort(() => Math.random() - 0.5) - const scene = generateScene(demoKinds.shift()!, demoSeed++) + const which = demoDying ? 'dying' : 'living' + demoDying = !demoDying + const all = which === 'dying' ? DYING_KINDS : LIVING_KINDS + if (!demoQueues[which].length) demoQueues[which] = params.has('seed') ? [...all] : [...all].sort(() => Math.random() - 0.5) + const scene = generateScene(demoQueues[which].shift()!, demoSeed++) if (!scene) continue resetView() demo.scene = scene diff --git a/src/sim/demo.ts b/src/sim/demo.ts index 1ba5d11..896896b 100644 --- a/src/sim/demo.ts +++ b/src/sim/demo.ts @@ -1,8 +1,9 @@ -import { ACTIONS, isAvailable } from './actions' +import { ACTIONS, isAvailable, isValidTarget } from './actions' import { predictDanger } from './danger' import { planIntents } from './enemies' import { Rng } from './rng' -import { idx, noBoosts, noPowers, START_LIVES, START_STATS } from './state' +import { playerPath } from './path' +import { exitOpen, idx, noBoosts, noPowers, START_LIVES, START_STATS } from './state' import { step } from './turn' import { DEFAULT_OPTIONS, @@ -29,6 +30,8 @@ export type SceneKind = | 'bombPlus' | 'firePlus' | 'fusePlus' | 'run' | 'kick' | 'throw' | 'teleport' | 'diagonal' | 'detonate' | 'wallPass' | 'bombPass' | 'flamePass' | 'star' | 'pierce' | 'power' | 'nuke' | 'shield' | 'freeze' | 'decoy' + | 'exit' | 'doorBlast' + | 'dieIntoBlast' | 'dieNotFar' | 'dieBlockedIn' | 'dieChain' | 'dieEnemy' | 'dieDetonator' | 'dieRun' export interface Scene { kind: SceneKind @@ -58,6 +61,10 @@ interface SceneDef { prepare?: (s: GameState, rng: Rng) => void /** Whether this turn is the moment the scene is about. */ shows: (t: Turn, seen: Seen) => boolean + /** A scene about a way to die: its moment is the turn the player dies (user request, 2026-10-23). */ + dies?: boolean + /** A nudge for the random play: an action to take now, or null for a random one. */ + steer?: (s: GameState, rng: Rng) => Action | null } const WIDTH = 9 @@ -73,6 +80,12 @@ const at = (p: Vec, q: Vec) => p.x === q.x && p.y === q.y const steps = (p: Vec, q: Vec) => Math.abs(p.x - q.x) + Math.abs(p.y - q.y) /** The player was in a blast's way (danger this turn) and the action took them out of it. */ const escaped = (t: Turn) => predictDanger(t.prev).now.has(idx(t.prev, t.prev.player)) && t.next.status === 'playing' +/** The player died this turn, and how: caught by flame (new or lingering), or by an enemy. */ +const died = (t: Turn) => t.events.some((e) => e.type === 'playerDied') +const burnt = (t: Turn) => + died(t) && (explosions(t).some((e) => e.tiles.some((p) => at(p, t.next.player))) || t.prev.burning.includes(idx(t.prev, t.next.player))) +const moved = (t: Turn) => !at(t.prev.player, t.next.player) +const inBlast = (t: Turn, p: Vec) => explosions(t).some((e) => e.tiles.some((q) => at(q, p))) /** A charge lying on a free floor tile two steps from the player, to walk over first. */ const dropping = (kind: ChargeId) => (s: GameState, rng: Rng) => dropNear(s, rng, kind) @@ -238,9 +251,89 @@ export const SCENES: Record<SceneKind, SceneDef> = { prepare: dropping('decoy'), shows: (t, seen) => seen.picked.has('decoy') && kills(t).length > 0 && !!t.prev.decoy, }, + // The exit (user request, 2026-10-23: "demos end naturally when the player walks into an open door"). + exit: { + caption: 'A crate hides the exit. Once every enemy is gone it opens: walk in to finish the level.', + enemies: ['blob'], + prepare: (s, rng) => placeExit(s, rng, true), + // Once the exit is open, mostly head for it. + steer: (s, rng) => { + if (!exitOpen(s) || rng.next() < 0.2) return null + const next = playerPath(s, s.exit)?.[0] + return next && isAvailable(s, 'move') && isValidTarget(s, 'move', next) ? { id: 'move', target: next } : null + }, + shows: (t) => t.events.some((e) => e.type === 'levelCleared'), + }, + doorBlast: { + caption: 'Don’t blast the exit once it’s uncovered: three more enemies come out.', + enemies: ['blob'], + prepare: (s, rng) => placeExit(s, rng, false), + shows: (t) => t.events.some((e) => e.type === 'enemiesSpawned'), + }, + // The ways to die. + dieIntoBlast: { + caption: 'Ouch! Walked straight into a blast. Check the red tiles before you step.', + enemies: ['blob'], + dies: true, + shows: (t) => burnt(t) && moved(t) && !inBlast(t, t.prev.player), + }, + dieNotFar: { + caption: 'Ouch! Not far enough: a blast reaches as far as its fire. Get further, or round a corner.', + enemies: ['blob'], + // A long blast, easy to run along and stay in. + prepare: (s) => (s.stats.fire = 3), + dies: true, + shows: (t) => burnt(t) && moved(t) && inBlast(t, t.prev.player), + }, + dieBlockedIn: { + caption: 'Ouch! Never put a bomb in your only way out.', + enemies: ['blob'], + dies: true, + // Nowhere to step that isn't about to burn. + shows: (t) => { + if (!burnt(t)) return false + const danger = predictDanger(t.prev).now + return ACTIONS.move.validTargets(t.prev).every((p) => danger.has(idx(t.prev, p))) + }, + }, + dieChain: { + caption: 'Ouch! One bomb set off another, and its blast reached you.', + enemies: ['blob'], + prepare: (s) => (s.stats.capacity = 2), + dies: true, + // Caught only by a bomb that wasn't due yet: the chain set it off. + shows: (t) => { + if (!burnt(t)) return false + const hits = explosions(t).filter((e) => e.tiles.some((p) => at(p, t.next.player))) + return hits.length > 0 && hits.every((e) => (t.prev.bombs.find((b) => at(b.pos, e.pos))?.timer ?? 0) > 1) + }, + }, + dieEnemy: { + caption: 'Ouch! Touching an enemy costs a life. Watch where its arrows go.', + enemies: ['seeker', 'blob'], + dies: true, + shows: (t) => died(t) && !burnt(t) && t.action.id !== 'run', + }, + dieDetonator: { + caption: 'Oops! The Detonator sets a bomb off at once, even with you right next to it.', + enemies: ['blob'], + prepare: (s) => ((s.powers.detonate = true), (s.stats.fuse = 6)), + dies: true, + shows: (t) => burnt(t) && t.events.some((e) => e.type === 'detonated'), + }, + dieRun: { + caption: 'Oops! Ran two tiles, straight into the enemy in between.', + enemies: ['seeker', 'seeker', 'blob'], + prepare: (s) => (s.powers.run = true), + dies: true, + shows: (t) => died(t) && !burnt(t) && t.action.id === 'run', + }, } export const SCENE_KINDS = Object.keys(SCENES) as SceneKind[] +/** The scenes about a way to die, and the rest, for the UI to take turns between. */ +export const DYING_KINDS = SCENE_KINDS.filter((k) => SCENES[k].dies) +export const LIVING_KINDS = SCENE_KINDS.filter((k) => !SCENES[k].dies) /** A scene of `kind`, from `seed`; null if no room gave one (rare). */ export function generateScene(kind: SceneKind, seed: number): Scene | null { @@ -323,6 +416,14 @@ function moreCrates(s: GameState, rng: Rng): void { }) } +/** The exit two steps from the player: under a crate, or uncovered (but locked while enemies live). */ +function placeExit(s: GameState, rng: Rng, covered: boolean): void { + const near = tilesAt(s, 2) + if (!near.length) return + s.exit = near[rng.int(near.length)]! + if (covered) s.cells[idx(s, s.exit)] = 'block' +} + /** Put a charge two steps from the player. */ function dropNear(s: GameState, rng: Rng, kind: ChargeId): void { const near = tilesAt(s, 2) @@ -349,17 +450,21 @@ function rollout(kind: SceneKind, start: GameState, rng: Rng): Action[] | null { const actions: Action[] = [] const seen: Seen = { picked: new Set(), used: new Set() } for (let n = 0; n < MAX_TURNS && s.status === 'playing' && !s.offer; n++) { - // A random action that doesn't get the player killed this turn (a few goes at it). + // A random action (or the scene's nudge) that doesn't get the player killed this turn, a few + // goes at it — unless dying is what the scene is about, and this is the way it shows. + const def = SCENES[kind] let turn: Turn | null = null for (let go = 0; go < 4 && !turn; go++) { - const action = randomAction(s, rng) + const action = def.steer?.(s, rng) ?? randomAction(s, rng) const r = step(s, action) - if (r.state.status === 'playing' || r.state.status === 'cleared') turn = { prev: s, action, events: r.events, next: r.state } + const t = { prev: s, action, events: r.events, next: r.state } + const alive = r.state.status === 'playing' || r.state.status === 'cleared' || r.state.status === 'won' + if (alive || (def.dies && def.shows(t, seen))) turn = t } if (!turn) return null actions.push(turn.action) remember(turn, seen) - if (SCENES[kind].shows(turn, seen)) return actions + if (def.shows(turn, seen) && !(def.dies && turn.next.status === 'playing')) return actions s = turn.next } return null diff --git a/src/style.css b/src/style.css index 25e81dc..77dc3f7 100644 --- a/src/style.css +++ b/src/style.css @@ -720,7 +720,8 @@ img { /* The demo before the game (main.ts): the board alone, a caption panel at the bottom. */ body.demo #hud, -body.demo #actions { +body.demo #actions, +body.demo #banner { display: none; } diff --git a/test/demo.test.ts b/test/demo.test.ts index 6613548..f327d63 100644 --- a/test/demo.test.ts +++ b/test/demo.test.ts @@ -1,17 +1,19 @@ import { describe, expect, it } from 'vitest' -import { generateScene, SCENE_KINDS, showsAtEnd } from '../src/sim/demo' +import { generateScene, SCENE_KINDS, SCENES, showsAtEnd } from '../src/sim/demo' import { step } from '../src/sim/turn' describe('demo scenes', () => { for (const kind of SCENE_KINDS) { - it(`${kind}: found, replayable with the player alive, and ending on what it shows`, () => { + it(`${kind}: found, replayable, the player alive (or dying at the end of a death scene), ending on what it shows`, () => { for (let seed = 1; seed <= 6; seed++) { const scene = generateScene(kind, seed)! expect(scene).not.toBeNull() + // Alive all along; at the end, dead in a scene about a way to die, alive in the rest. let s = scene.start - for (const a of scene.actions) { + for (const [i, a] of scene.actions.entries()) { s = step(s, a).state - expect(['playing', 'cleared']).toContain(s.status) + const last = i === scene.actions.length - 1 + expect(last && SCENES[kind].dies ? ['dead', 'gameOver'] : ['playing', 'cleared']).toContain(s.status) } expect(showsAtEnd(scene)).toBe(true) }