explodorama / files / src / sim / demo.ts
demo.ts
d5e3ad4d 2026-10-24 Pavel Safronov | 1 | import { ACTIONS, isAvailable, isValidTarget } from './actions' |
e3d8dc49 2026-10-27 Pavel Safronov | 2 | import { isDeadly, predictDanger } from './danger' |
b8849d36 2026-11-01 Pavel Safronov | 3 | import { blastTiles, COLLAPSE_TURNS } from './explosions' |
52d68b59 2026-10-24 Pavel Safronov | 4 | import { planIntents } from './enemies' |
| 5 | import { Rng } from './rng' | |
36c5d6a2 2026-10-24 Pavel Safronov | 6 | import { autoMoveAction, playerPath, safeStep } from './path' |
| 7 | import { membersOf, resolvePick, tapMeaning } from './picks' | |
1f0038ac 2026-11-01 Pavel Safronov | 8 | import { exitOpen, idx, inBounds, noPowers, playerCanEnter, START_LIVES, START_STATS } from './state' |
8edb5cb9 2026-11-01 Pavel Safronov | 9 | import { buildTestScene, TEST_KINDS, TEST_SCENES, type TestKind } from './testScenes' |
52d68b59 2026-10-24 Pavel Safronov | 10 | import { step } from './turn' |
fa3e81a7 2026-10-24 Pavel Safronov | 11 | import { |
| 12 | DEFAULT_OPTIONS, | |
36c5d6a2 2026-10-24 Pavel Safronov | 13 | DIRS, |
| 14 | DIR_LIST, | |
fa3e81a7 2026-10-24 Pavel Safronov | 15 | type Action, |
| 16 | type ActionId, | |
| 17 | type Cell, | |
| 18 | type ChargeId, | |
| 19 | type EnemyKind, | |
| 20 | type GameEvent, | |
| 21 | type GameState, | |
| 22 | type Vec, | |
| 23 | } from './types' | |
52d68b59 2026-10-24 Pavel Safronov | 24 | |
| 25 | /** | |
| 26 | * The demo played before the game starts, for players who never played the original (user | |
| 27 | * request, 2026-10-23). Each scene is made up on the spot: a small random room, then random play | |
| 28 | * on it, by the real rules, until a game shows what the scene is about. The scene is that room | |
| 29 | * and those actions; the UI replays them, view-only. Pure and seeded like the rest of the sim. | |
fa3e81a7 2026-10-24 Pavel Safronov | 30 | * The scenes show every feature that happens on the board (user request, 2026-10-23: all but |
| 31 | * the shop and the exit) — Heart and Coin bag only change numbers, so they have none. | |
36c5d6a2 2026-10-24 Pavel Safronov | 32 | * Since 2026-10-24 (user request) they also show the controls: each turn comes with the gesture |
| 33 | * that plays it (a tap, a long press, a button then a tile), which the UI acts out with a finger, | |
b8849d36 2026-11-01 Pavel Safronov | 34 | * and a few scenes are about the controls alone. And each scene's moment comes 3 to 6 turns in (`maxSteps` asks for more, `tail` for turns after it): |
36c5d6a2 2026-10-24 Pavel Safronov | 35 | * the scene is the last few turns of a longer game, from a calm moment (no bomb, no flame). |
52d68b59 2026-10-24 Pavel Safronov | 36 | */ |
fa3e81a7 2026-10-24 Pavel Safronov | 37 | export type SceneKind = |
36c5d6a2 2026-10-24 Pavel Safronov | 38 | | 'tapMove' | 'holdBomb' | 'bombButton' | 'wait' | 'autoMove' |
fa3e81a7 2026-10-24 Pavel Safronov | 39 | | 'kill' | 'arrows' | 'escape' | 'chain' | 'lingering' | 'enemies' | 'coins' | 'gem' |
| 40 | | 'bombPlus' | 'firePlus' | 'fusePlus' | 'run' | 'kick' | 'throw' | 'teleport' | 'diagonal' | 'detonate' | |
7f242d36 2026-10-25 Pavel Safronov | 41 | | 'rpg' | 'proximity' | 'slide' | 'icbm' |
fa3e81a7 2026-10-24 Pavel Safronov | 42 | | 'wallPass' | 'bombPass' | 'flamePass' | 'star' |
b8849d36 2026-11-01 Pavel Safronov | 43 | | 'pierce' | 'power' | 'nuke' | 'acid' | 'rubble' | 'shield' | 'freeze' | 'decoy' | 'chargeSwap' |
d5e3ad4d 2026-10-24 Pavel Safronov | 44 | | 'exit' | 'doorBlast' |
ac534e57 2026-10-25 Pavel Safronov | 45 | | 'lastEnemy' | 'crate' | 'pillar' | 'rainbowGem' | 'immortal' |
1f0038ac 2026-11-01 Pavel Safronov | 46 | | 'bush' | 'bushFire' | 'metal' | 'oil' | 'oilSlide' | 'spikes' | 'barrel' |
8edb5cb9 2026-11-01 Pavel Safronov | 47 | | TestKind |
d5e3ad4d 2026-10-24 Pavel Safronov | 48 | | 'dieIntoBlast' | 'dieNotFar' | 'dieBlockedIn' | 'dieChain' | 'dieEnemy' | 'dieDetonator' | 'dieRun' |
52d68b59 2026-10-24 Pavel Safronov | 49 | |
36c5d6a2 2026-10-24 Pavel Safronov | 50 | /** |
| 51 | * How the player plays a turn, for the demo's finger (main.ts): every gesture is one the real | |
| 52 | * controls read as that turn's action (test/demo.test.ts checks it). | |
| 53 | * - tap: a tap on a board tile, nothing picked (picks.ts `tapMeaning`); | |
| 54 | * - hold: a long press on a tile next to the player: a bomb there; | |
| 55 | * - button: an action button, then (unless it acts at once, as Wait) a tap on a tile; | |
| 56 | * - walk: a tap on a far tile: auto-move's destination, and its first move; | |
| 57 | * - go: the Move button while auto-move has a destination (it shows as Go): the next move. | |
| 58 | */ | |
| 59 | export type Gesture = | |
| 60 | | { kind: 'tap'; tile: Vec } | |
| 61 | | { kind: 'hold'; tile: Vec } | |
| 62 | | { kind: 'walk'; tile: Vec } | |
| 63 | | { kind: 'button'; button: ButtonId; tile?: Vec } | |
| 64 | | { kind: 'go' } | |
0bfdc8e6 2026-10-25 Pavel Safronov | 65 | /** A charge picked up while carrying another: a tap on the dialog's Take (swap) or Keep button. */ |
| 66 | | { kind: 'choice'; swap: boolean } | |
8edb5cb9 2026-11-01 Pavel Safronov | 67 | /** A gem's offer, or a charge's sell-or-swap, answered (test scenes; the demo opens the dialog). */ |
| 68 | | { kind: 'pick' } | |
36c5d6a2 2026-10-24 Pavel Safronov | 69 | |
| 70 | /** The action bar's buttons (ui/controls.ts). */ | |
d3e4ed26 2026-10-25 Pavel Safronov | 71 | export type ButtonId = 'move' | 'bomb' | 'charged' | 'wait' |
36c5d6a2 2026-10-24 Pavel Safronov | 72 | |
52d68b59 2026-10-24 Pavel Safronov | 73 | export interface Scene { |
| 74 | kind: SceneKind | |
| 75 | start: GameState | |
| 76 | actions: Action[] | |
36c5d6a2 2026-10-24 Pavel Safronov | 77 | /** How each action is played, one per action. */ |
| 78 | gestures: Gesture[] | |
b8849d36 2026-11-01 Pavel Safronov | 79 | /** The turn (index in `actions`) the scene is about; any after it are the aftermath (`SceneDef.tail`). Last if absent. */ |
| 80 | moment?: number | |
52d68b59 2026-10-24 Pavel Safronov | 81 | } |
| 82 | ||
fa3e81a7 2026-10-24 Pavel Safronov | 83 | /** What a scene's game has done so far, for moments that need an earlier turn (a charge picked up). */ |
| 84 | interface Seen { | |
| 85 | picked: Set<ChargeId> | |
36c5d6a2 2026-10-24 Pavel Safronov | 86 | /** Each kind of action taken, and how many times. */ |
| 87 | used: Map<ActionId, number> | |
| 88 | /** The far tile auto-move was last sent to. */ | |
| 89 | walk: Vec | null | |
| 90 | } | |
| 91 | ||
| 92 | /** One turn as played: the action and its gesture. */ | |
| 93 | interface Play { | |
| 94 | action: Action | |
| 95 | gesture: Gesture | |
fa3e81a7 2026-10-24 Pavel Safronov | 96 | } |
| 97 | ||
36c5d6a2 2026-10-24 Pavel Safronov | 98 | /** A game's auto-move: the far tile it is walking to, or null. */ |
| 99 | interface Walking { | |
| 100 | to: Vec | null | |
| 101 | } | |
| 102 | ||
| 103 | /** One turn of a game: the state before it, the action and its gesture, its events and the state after. */ | |
fa3e81a7 2026-10-24 Pavel Safronov | 104 | interface Turn { |
| 105 | prev: GameState | |
| 106 | action: Action | |
36c5d6a2 2026-10-24 Pavel Safronov | 107 | gesture: Gesture |
fa3e81a7 2026-10-24 Pavel Safronov | 108 | events: GameEvent[] |
| 109 | next: GameState | |
| 110 | } | |
| 111 | ||
| 112 | interface SceneDef { | |
| 113 | /** One line under the scene. */ | |
| 114 | caption: string | |
92cc4ee3 2026-11-02 Pavel Safronov | 115 | /** The caption when a gamepad is in use (Q58): `{dpad}`, `{move}` ... stand for the controller's pictures (ui/padGlyph.ts `padCaptionHtml`). Absent: `caption` is used. */ |
| 116 | padCaption?: string | |
| 117 | /** The caption for the keyboard demo (Q65): `{arrows}`, `{M}`, `{shift}` ... stand for key pictures (ui/keyDemo.ts `keyCaptionHtml`). Absent: `caption` is used. */ | |
| 118 | keyCaption?: string | |
fa3e81a7 2026-10-24 Pavel Safronov | 119 | enemies: EnemyKind[] |
| 120 | /** What the player starts with, or what lies near them. */ | |
| 121 | prepare?: (s: GameState, rng: Rng) => void | |
| 122 | /** Whether this turn is the moment the scene is about. */ | |
| 123 | shows: (t: Turn, seen: Seen) => boolean | |
d5e3ad4d 2026-10-24 Pavel Safronov | 124 | /** A scene about a way to die: its moment is the turn the player dies (user request, 2026-10-23). */ |
| 125 | dies?: boolean | |
32dd53cd 2026-10-25 Pavel Safronov | 126 | /** Only for a dialog's clip (ui/clip.ts), not in the demo. */ |
| 127 | clipOnly?: boolean | |
36c5d6a2 2026-10-24 Pavel Safronov | 128 | /** A nudge for the random play: a turn to play now, or null for a random one. */ |
| 129 | steer?: (s: GameState, rng: Rng, walking: Walking) => Play | null | |
| 130 | /** Bombs placed with the Bomb button and a tap, not a long press. */ | |
| 131 | bombButton?: boolean | |
b8849d36 2026-11-01 Pavel Safronov | 132 | /** More turns to a scene than MAX_STEPS, for a moment that takes a while to come (rubble). */ |
| 133 | maxSteps?: number | |
| 134 | /** Turns of waiting played after the moment, as far as they are safe, to see its result (user request, 2026-10-31). */ | |
| 135 | tail?: number | |
8edb5cb9 2026-11-01 Pavel Safronov | 136 | /** A fixed scene (sim/testScenes.ts), not rolled: Developer mode's edge-case checks. Not in the demo's rotation. */ |
| 137 | fixed?: () => Scene | |
0bfdc8e6 2026-10-25 Pavel Safronov | 138 | /** A scene about the sell-or-swap choice: turns that ask it are kept (elsewhere they are skipped). */ |
| 139 | choice?: boolean | |
fa3e81a7 2026-10-24 Pavel Safronov | 140 | } |
| 141 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 142 | /** The scenes about the controls alone, which the gamepad's demo has its own version of (Q58). */ |
| 143 | export const PAD_CONTROL_KINDS: readonly SceneKind[] = ['tapMove', 'bombButton', 'wait'] | |
| 144 | ||
| 145 | /** The scenes the gamepad's demo leaves out for now: they ask a dialog's question (the sell-or-swap choice), which the pad demo cannot show yet. */ | |
| 146 | export const PAD_LATER: readonly SceneKind[] = ['chargeSwap'] | |
| 147 | ||
52d68b59 2026-10-24 Pavel Safronov | 148 | const WIDTH = 9 |
| 149 | const HEIGHT = 7 | |
36c5d6a2 2026-10-24 Pavel Safronov | 150 | /** How many turns a random game may take, how many are tried per room, and how many rooms. */ |
52d68b59 2026-10-24 Pavel Safronov | 151 | const MAX_TURNS = 24 |
| 152 | const TRIES = 150 | |
fa3e81a7 2026-10-24 Pavel Safronov | 153 | const ROOMS = 8 |
36c5d6a2 2026-10-24 Pavel Safronov | 154 | /** A scene's moment comes this many turns in, at the least and at the most (user request, 2026-10-24). */ |
| 155 | export const MIN_STEPS = 3 | |
| 156 | export const MAX_STEPS = 6 | |
e3384c6b 2026-10-24 Pavel Safronov | 157 | /** A room's par, as a level of about its size would have (the top bar shows it). */ |
| 158 | const DEMO_PAR = 20 | |
fa3e81a7 2026-10-24 Pavel Safronov | 159 | |
| 160 | const kills = (t: Turn) => t.events.filter((e): e is Extract<GameEvent, { type: 'enemyDied' }> => e.type === 'enemyDied') | |
| 161 | const explosions = (t: Turn) => t.events.filter((e): e is Extract<GameEvent, { type: 'exploded' }> => e.type === 'exploded') | |
| 162 | const at = (p: Vec, q: Vec) => p.x === q.x && p.y === q.y | |
| 163 | const steps = (p: Vec, q: Vec) => Math.abs(p.x - q.x) + Math.abs(p.y - q.y) | |
| 164 | /** The player was in a blast's way (danger this turn) and the action took them out of it. */ | |
| 165 | const escaped = (t: Turn) => predictDanger(t.prev).now.has(idx(t.prev, t.prev.player)) && t.next.status === 'playing' | |
d5e3ad4d 2026-10-24 Pavel Safronov | 166 | /** The player died this turn, and how: caught by flame (new or lingering), or by an enemy. */ |
| 167 | const died = (t: Turn) => t.events.some((e) => e.type === 'playerDied') | |
| 168 | const burnt = (t: Turn) => | |
| 169 | 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))) | |
32dd53cd 2026-10-25 Pavel Safronov | 170 | /** A tile of the room's outer wall (the rest of the walls are pillars). */ |
| 171 | const isEdge = (s: GameState, p: Vec) => p.x === 0 || p.y === 0 || p.x === s.width - 1 || p.y === s.height - 1 | |
d5e3ad4d 2026-10-24 Pavel Safronov | 172 | const moved = (t: Turn) => !at(t.prev.player, t.next.player) |
| 173 | const inBlast = (t: Turn, p: Vec) => explosions(t).some((e) => e.tiles.some((q) => at(q, p))) | |
36c5d6a2 2026-10-24 Pavel Safronov | 174 | /** A charge lying on a free floor tile two steps from the player (or `n`), to walk over first. */ |
| 175 | const dropping = (kind: ChargeId, n = 2) => (s: GameState, rng: Rng) => dropNear(s, rng, kind, n) | |
fa3e81a7 2026-10-24 Pavel Safronov | 176 | |
9ed991c9 2026-11-03 Pavel Safronov | 177 | /** |
| 178 | * A nudge for the Decoy scene: head for its drop, then put it down. Random play rarely chains | |
| 179 | * pickup, use and a kill (seeds 1-6 took 3.2 s to find, now 0.9 s); a little randomness stays so | |
| 180 | * the scenes differ. (Tried for Freeze too: no faster, its slow part is the kill while frozen.) | |
| 181 | */ | |
| 182 | const fetchAndUse = (kind: 'decoy') => (s: GameState, rng: Rng): Play | null => { | |
| 183 | if (rng.next() < 0.15) return null | |
| 184 | if (isAvailable(s, kind)) return playOf(SCENES[kind], s, randomTarget(s, rng, kind)) | |
| 185 | const drop = s.drops.find((d) => d.kind === kind) | |
| 186 | const next = drop && playerPath(s, drop.pos)?.[0] | |
| 187 | return next && isAvailable(s, 'move') && isValidTarget(s, 'move', next) ? playOf(SCENES[kind], s, { id: 'move', target: next }) : null | |
| 188 | } | |
| 189 | ||
8edb5cb9 2026-11-01 Pavel Safronov | 190 | /** A test scene's entry (sim/testScenes.ts). */ |
| 191 | const testDef = (kind: TestKind): SceneDef => ({ | |
| 192 | caption: TEST_SCENES[kind].caption, | |
| 193 | enemies: [], | |
| 194 | shows: () => true, | |
| 195 | clipOnly: true, | |
| 196 | fixed: () => buildTestScene(kind), | |
| 197 | }) | |
| 198 | ||
fa3e81a7 2026-10-24 Pavel Safronov | 199 | export const SCENES: Record<SceneKind, SceneDef> = { |
8edb5cb9 2026-11-01 Pavel Safronov | 200 | ...(Object.fromEntries(TEST_KINDS.map((k) => [k, testDef(k)])) as Record<TestKind, SceneDef>), |
36c5d6a2 2026-10-24 Pavel Safronov | 201 | // The controls (user request, 2026-10-24), shown first. |
| 202 | tapMove: { | |
| 203 | caption: 'Tap a tile next to you to step onto it.', | |
92cc4ee3 2026-11-02 Pavel Safronov | 204 | padCaption: 'Move the cursor to a tile next to you with {dpad}, then press {move} to step there.', |
| 205 | keyCaption: 'Press {arrows} (or W A S D) to step onto the tile next to you that way.', | |
36c5d6a2 2026-10-24 Pavel Safronov | 206 | enemies: ['blob'], |
| 207 | steer: (s, rng) => playOf(SCENES.tapMove, s, randomTarget(s, rng, 'move')), | |
| 208 | shows: (t, seen) => t.action.id === 'move' && (seen.used.get('move') ?? 0) >= MIN_STEPS, | |
| 209 | }, | |
| 210 | holdBomb: { | |
| 211 | caption: 'Hold your finger on a tile next to you: a bomb goes there when you let go. Then get away!', | |
| 212 | enemies: ['blob'], | |
| 213 | shows: (t) => explosions(t).length > 0, | |
| 214 | }, | |
| 215 | bombButton: { | |
| 216 | caption: 'Or press Bomb, then tap where the bomb goes.', | |
92cc4ee3 2026-11-02 Pavel Safronov | 217 | padCaption: 'Put the cursor next to you and press {bomb}: a bomb goes there. Then get away!', |
| 218 | keyCaption: 'Press {B}, then {arrows}: a bomb goes on the tile next to you that way. Then get away!', | |
36c5d6a2 2026-10-24 Pavel Safronov | 219 | enemies: ['blob'], |
| 220 | bombButton: true, | |
| 221 | shows: (t) => explosions(t).length > 0, | |
| 222 | }, | |
| 223 | wait: { | |
| 224 | caption: 'Press Wait to stay put for a turn, and let an enemy walk into your blast.', | |
92cc4ee3 2026-11-02 Pavel Safronov | 225 | padCaption: 'The cursor starts on you each turn: press {wait} to stay put, and let an enemy walk into your blast.', |
| 226 | keyCaption: 'Press {space} to stay put for a turn, and let an enemy walk into your blast.', | |
36c5d6a2 2026-10-24 Pavel Safronov | 227 | enemies: ['blob', 'seeker'], |
| 228 | shows: (t, seen) => seen.used.has('wait') && kills(t).length > 0, | |
| 229 | }, | |
| 230 | autoMove: { | |
| 231 | caption: 'Tap a tile further away to walk there: the Move button turns into Go, one step per press.', | |
| 232 | enemies: ['blob'], | |
| 233 | // A far tile to walk to, then Go until there. | |
| 234 | steer: (s, rng, walking) => { | |
| 235 | const danger = predictDanger(s) | |
| 236 | const safe = (p: Vec) => safeStep(s, danger, p) | |
| 237 | if (!walking.to) { | |
| 238 | const far = s.cells.flatMap((_, i) => { | |
| 239 | const p = { x: i % s.width, y: Math.floor(i / s.width) } | |
| 240 | const path = tapMeaning(s, p) === 'walk' ? playerPath(s, p) : null | |
| 241 | return path && path.length >= 3 && path.length <= 4 ? [p] : [] | |
| 242 | }) | |
| 243 | if (!far.length) return null | |
| 244 | const to = far[rng.int(far.length)]! | |
| 245 | const action = autoMoveAction(s, to, safe) | |
e3d8dc49 2026-10-27 Pavel Safronov | 246 | if (!action || isDeadly(s, action)) return null |
36c5d6a2 2026-10-24 Pavel Safronov | 247 | walking.to = to |
| 248 | return { action, gesture: { kind: 'walk', tile: to } } | |
| 249 | } | |
e3d8dc49 2026-10-27 Pavel Safronov | 250 | // Not safe yet: wait for the way to clear (the destination stays). Go never takes a deadly move. |
36c5d6a2 2026-10-24 Pavel Safronov | 251 | const action = autoMoveAction(s, walking.to, safe) |
e3d8dc49 2026-10-27 Pavel Safronov | 252 | return action && !isDeadly(s, action) ? { action, gesture: { kind: 'go' } } : { action: { id: 'wait' }, gesture: { kind: 'button', button: 'wait' } } |
36c5d6a2 2026-10-24 Pavel Safronov | 253 | }, |
| 254 | shows: (t, seen) => !!seen.walk && at(t.next.player, seen.walk), | |
| 255 | }, | |
fa3e81a7 2026-10-24 Pavel Safronov | 256 | kill: { |
| 257 | caption: 'Bombs count down with your moves. Their flame kills enemies.', | |
| 258 | enemies: ['blob', 'seeker'], | |
| 259 | shows: (t) => t.next.enemies.length === 0, | |
| 260 | }, | |
| 261 | arrows: { | |
| 262 | caption: 'The red arrows are each enemy’s next 3 moves. Watch them, and bomb where an enemy is going.', | |
| 263 | enemies: ['blob', 'drifter'], | |
| 264 | // An enemy killed on the tile it stepped onto this turn: the bomb was waiting where its arrows led. | |
| 265 | shows: (t) => kills(t).some((k) => t.events.some((e) => e.type === 'enemyMoved' && e.id === k.id && at(e.to, k.pos)) && explosions(t).length > 0), | |
| 266 | }, | |
| 267 | escape: { | |
| 268 | caption: 'Step out of the blast just in time.', | |
| 269 | enemies: ['seeker'], | |
| 270 | // Stood in a blast's reach, stepped out of it on the very turn it went off. | |
| 271 | shows: (t) => explosions(t).some((e) => e.tiles.some((p) => at(p, t.prev.player))), | |
| 272 | }, | |
| 273 | chain: { | |
| 274 | caption: 'A blast sets off every bomb it reaches.', | |
| 275 | enemies: ['blob', 'blob'], | |
| 276 | prepare: (s) => (s.stats.capacity = 2), | |
| 277 | shows: (t) => explosions(t).length >= 2 && kills(t).length > 0, | |
| 278 | }, | |
| 279 | lingering: { | |
| 280 | caption: 'Flame keeps burning for one more turn: enemies that walk in die.', | |
| 281 | enemies: ['blob', 'drifter'], | |
| 282 | shows: (t) => kills(t).some((k) => t.prev.burning.includes(idx(t.prev, k.pos))), | |
| 283 | }, | |
| 284 | enemies: { | |
565a6873 2026-10-25 Pavel Safronov | 285 | caption: 'Each kind of enemy moves its own way: Blobs wander, Seekers chase you, Drifters go straight. More kinds come later.', |
fa3e81a7 2026-10-24 Pavel Safronov | 286 | enemies: ['blob', 'seeker', 'drifter'], |
| 287 | shows: (t) => kills(t).length > 0, | |
| 288 | }, | |
| 289 | coins: { | |
| 290 | caption: 'Enemies sometimes leave coins or a charge. Walk over them to take them.', | |
| 291 | enemies: ['seeker', 'seeker'], | |
| 292 | shows: (t) => t.events.some((e) => e.type === 'pickedUp'), | |
| 293 | }, | |
| 294 | gem: { | |
| 295 | caption: 'Every level hides a gem under a crate. Take it to choose a power-up.', | |
| 296 | enemies: ['blob'], | |
| 297 | prepare: (s, rng) => hideGem(s, rng), | |
| 298 | shows: (t) => t.events.some((e) => e.type === 'powerUpTaken'), | |
| 299 | }, | |
| 300 | bombPlus: { | |
| 301 | caption: 'Bomb +1: one more bomb on the board at a time.', | |
| 302 | enemies: ['blob', 'seeker'], | |
| 303 | prepare: (s) => (s.stats.capacity = 3), | |
| 304 | shows: (t) => t.prev.bombs.length >= 2 && kills(t).length > 0, | |
| 305 | }, | |
| 306 | firePlus: { | |
| 307 | caption: 'Fire +1: your blasts reach further.', | |
| 308 | enemies: ['blob', 'drifter'], | |
| 309 | prepare: (s) => (s.stats.fire = 3), | |
| 310 | shows: (t) => kills(t).some((k) => explosions(t).some((e) => steps(e.pos, k.pos) >= 2)), | |
| 311 | }, | |
| 312 | fusePlus: { | |
| 313 | caption: 'Fuse +1: your bombs start higher, so you have longer to get away.', | |
| 314 | enemies: ['seeker'], | |
36c5d6a2 2026-10-24 Pavel Safronov | 315 | prepare: (s) => (s.stats.fuse = START_STATS.fuse + 1), |
fa3e81a7 2026-10-24 Pavel Safronov | 316 | shows: (t) => kills(t).length > 0, |
| 317 | }, | |
| 318 | run: { | |
36c5d6a2 2026-10-24 Pavel Safronov | 319 | caption: 'Run: press Move, then tap two tiles away — out of trouble fast.', |
92cc4ee3 2026-11-02 Pavel Safronov | 320 | padCaption: 'Run: put the cursor two tiles away and press {move} — out of trouble fast.', |
| 321 | keyCaption: 'Run: {M} and {arrows} reach a tile two away, or put the {shift} cursor there and press {M} — out of trouble fast.', | |
fa3e81a7 2026-10-24 Pavel Safronov | 322 | enemies: ['seeker', 'blob'], |
| 323 | prepare: (s) => (s.powers.run = true), | |
| 324 | shows: (t) => t.action.id === 'run' && escaped(t), | |
| 325 | }, | |
| 326 | kick: { | |
36c5d6a2 2026-10-24 Pavel Safronov | 327 | caption: 'Kick: tap a bomb next to you and it slides away until something stops it.', |
92cc4ee3 2026-11-02 Pavel Safronov | 328 | padCaption: 'Kick: put the cursor on a bomb next to you and press {move}: it slides away until something stops it.', |
| 329 | keyCaption: 'Kick: press {arrows} into a bomb next to you, or put the {shift} cursor on it and press {M}: it slides away until something stops it.', | |
fa3e81a7 2026-10-24 Pavel Safronov | 330 | enemies: ['blob', 'drifter'], |
| 331 | prepare: (s) => (s.powers.kick = true), | |
| 332 | shows: (t, seen) => seen.used.has('kick') && kills(t).length > 0, | |
| 333 | }, | |
| 334 | throw: { | |
36c5d6a2 2026-10-24 Pavel Safronov | 335 | caption: 'Throw: press Bomb, then tap two tiles away — over a crate, a bomb or an enemy.', |
92cc4ee3 2026-11-02 Pavel Safronov | 336 | padCaption: 'Throw: put the cursor two tiles away and press {bomb} — over a crate, a bomb or an enemy.', |
| 337 | keyCaption: 'Throw: {B} and {arrows} reach a tile two away, or put the {shift} cursor there and press {B} — over a crate, a bomb or an enemy.', | |
fa3e81a7 2026-10-24 Pavel Safronov | 338 | enemies: ['blob', 'seeker'], |
| 339 | prepare: (s) => (s.powers.throw = true), | |
| 340 | shows: (t, seen) => seen.used.has('throw') && kills(t).length > 0, | |
| 341 | }, | |
| 342 | teleport: { | |
fcf44f32 2026-10-25 Pavel Safronov | 343 | caption: 'Teleport: press the charge button, then tap any empty tile up to 3 steps away, over anything.', |
92cc4ee3 2026-11-02 Pavel Safronov | 344 | padCaption: 'Teleport: put the cursor on any empty tile up to 3 steps away and press {charge}, over anything.', |
| 345 | keyCaption: 'Teleport: put the {shift} cursor on any empty tile up to 3 steps away and press {V}, over anything.', | |
fa3e81a7 2026-10-24 Pavel Safronov | 346 | enemies: ['seeker', 'blob'], |
fcf44f32 2026-10-25 Pavel Safronov | 347 | prepare: (s) => (s.charge = { kind: 'teleport', left: 3 }), |
fa3e81a7 2026-10-24 Pavel Safronov | 348 | shows: (t) => t.action.id === 'teleport' && escaped(t), |
| 349 | }, | |
| 350 | diagonal: { | |
36c5d6a2 2026-10-24 Pavel Safronov | 351 | caption: 'Diagonal: tap a corner tile to step diagonally, even between two blocked tiles.', |
92cc4ee3 2026-11-02 Pavel Safronov | 352 | padCaption: 'Diagonal: put the cursor on a corner tile and press {move} to step diagonally, even between two blocked tiles.', |
| 353 | keyCaption: 'Diagonal: put the {shift} cursor on a corner tile and press {M} to step diagonally, even between two blocked tiles.', | |
fa3e81a7 2026-10-24 Pavel Safronov | 354 | enemies: ['seeker', 'blob'], |
| 355 | prepare: (s) => (s.powers.diagonal = true), | |
| 356 | shows: (t) => t.action.id === 'diagonal' && escaped(t), | |
| 357 | }, | |
| 358 | detonate: { | |
36c5d6a2 2026-10-24 Pavel Safronov | 359 | caption: 'Detonator: tap your bomb to set it off right when an enemy walks in.', |
92cc4ee3 2026-11-02 Pavel Safronov | 360 | padCaption: 'Detonator: put the cursor on your bomb and press {bomb} to set it off right when an enemy walks in.', |
| 361 | keyCaption: 'Detonator: put the {shift} cursor on your bomb and press {B} to set it off right when an enemy walks in.', | |
fa3e81a7 2026-10-24 Pavel Safronov | 362 | enemies: ['blob', 'seeker'], |
| 363 | // A long fuse, so the Detonator is what sets the bomb off. | |
| 364 | prepare: (s) => ((s.powers.detonate = true), (s.stats.fuse = 6)), | |
| 365 | shows: (t) => t.events.some((e) => e.type === 'detonated') && kills(t).length > 0, | |
| 366 | }, | |
7f242d36 2026-10-25 Pavel Safronov | 367 | // Added 2026-10-24 (user request), with their power-ups and charge. |
| 368 | rpg: { | |
| 369 | caption: 'RPG: press Bomb, then tap where it lands — it flies as far as it can.', | |
92cc4ee3 2026-11-02 Pavel Safronov | 370 | padCaption: 'RPG: put the cursor where it lands and press {bomb} — it flies as far as it can.', |
| 371 | keyCaption: 'RPG: {B} and {arrows} aim it, or put the {shift} cursor where it lands and press {B} — it flies as far as it can.', | |
7f242d36 2026-10-25 Pavel Safronov | 372 | enemies: ['blob', 'drifter'], |
| 373 | prepare: (s) => (s.powers.rpg = true), | |
| 374 | shows: (t, seen) => seen.used.has('rpg') && kills(t).length > 0, | |
| 375 | }, | |
| 376 | proximity: { | |
d3e4ed26 2026-10-25 Pavel Safronov | 377 | caption: 'Proximity alert: your bombs go off as soon as an enemy is right next to one.', |
7f242d36 2026-10-25 Pavel Safronov | 378 | enemies: ['blob', 'seeker'], |
| 379 | prepare: (s) => (s.powers.proximity = true), | |
| 380 | shows: (t) => t.events.some((e) => e.type === 'proximityTriggered') && kills(t).length > 0, | |
| 381 | }, | |
| 382 | slide: { | |
| 383 | caption: 'Slide: tap where you stop — you slide until a wall, crate or bomb.', | |
92cc4ee3 2026-11-02 Pavel Safronov | 384 | padCaption: 'Slide: put the cursor where you stop and press {move} — you slide until a wall, crate or bomb.', |
| 385 | keyCaption: 'Slide: {M} and {arrows} aim it, or put the {shift} cursor where you stop and press {M} — you slide until a wall, crate or bomb.', | |
7f242d36 2026-10-25 Pavel Safronov | 386 | enemies: ['blob'], |
| 387 | prepare: (s) => (s.powers.slide = true), | |
| 388 | shows: (t) => t.action.id === 'slide' && steps(t.prev.player, t.next.player) >= 3, | |
| 389 | }, | |
| 390 | icbm: { | |
d3e4ed26 2026-10-25 Pavel Safronov | 391 | caption: 'ICBM: press the charge button, then tap where it lands — it goes off at once.', |
92cc4ee3 2026-11-02 Pavel Safronov | 392 | padCaption: 'ICBM: put the cursor where it lands and press {charge} — it goes off at once.', |
| 393 | keyCaption: 'ICBM: put the {shift} cursor where it lands and press {V} — it goes off at once.', | |
7f242d36 2026-10-25 Pavel Safronov | 394 | enemies: ['blob', 'drifter'], |
| 395 | prepare: dropping('icbm', 1), | |
| 396 | shows: (t) => t.action.id === 'icbm' && kills(t).length > 0, | |
| 397 | }, | |
fa3e81a7 2026-10-24 Pavel Safronov | 398 | wallPass: { |
ebcd0a8f 2026-11-02 Pavel Safronov | 399 | caption: 'Crate Pass: walk through crates.', |
fa3e81a7 2026-10-24 Pavel Safronov | 400 | enemies: ['blob'], |
| 401 | prepare: (s) => (s.powers.wallPass = true), | |
| 402 | shows: (t) => t.events.some((e) => e.type === 'moved' && t.prev.cells[idx(t.prev, e.to)] === 'block'), | |
| 403 | }, | |
| 404 | bombPass: { | |
| 405 | caption: 'Bomb Pass: walk over bombs.', | |
| 406 | enemies: ['blob'], | |
| 407 | prepare: (s) => (s.powers.bombPass = true), | |
| 408 | shows: (t) => t.events.some((e) => e.type === 'moved' && t.prev.bombs.some((b) => at(b.pos, e.to))), | |
| 409 | }, | |
| 410 | flamePass: { | |
5f066960 2026-10-26 Pavel Safronov | 411 | caption: 'Flame Pass: while you carry it, the next 3 fire hits can’t hurt you.', |
fa3e81a7 2026-10-24 Pavel Safronov | 412 | enemies: ['blob'], |
5f066960 2026-10-26 Pavel Safronov | 413 | prepare: (s) => (s.charge = { kind: 'flamePass', left: 3 }), |
fa3e81a7 2026-10-24 Pavel Safronov | 414 | shows: (t) => explosions(t).some((e) => e.tiles.some((p) => at(p, t.next.player))), |
| 415 | }, | |
| 416 | star: { | |
| 417 | caption: 'Star: nothing can hurt you for 8 turns.', | |
| 418 | enemies: ['seeker', 'blob'], | |
| 419 | prepare: (s) => (s.star = 8), | |
| 420 | shows: (t) => explosions(t).some((e) => e.tiles.some((p) => at(p, t.next.player))) || t.next.enemies.some((e) => at(e.pos, t.next.player)), | |
| 421 | }, | |
| 422 | pierce: { | |
d3e4ed26 2026-10-25 Pavel Safronov | 423 | caption: 'Pierce: the charge button puts down bombs that burn through crates.', |
fa3e81a7 2026-10-24 Pavel Safronov | 424 | enemies: ['blob'], |
36c5d6a2 2026-10-24 Pavel Safronov | 425 | // Fire 2, and a row of two crates to burn through, with the charge on the way there. |
| 426 | prepare: (s, rng) => ((s.stats.fire = 2), crateRow(s, rng)), | |
| 427 | // A crate burnt behind another one: without Pierce the first would have stopped the flame. | |
| 428 | shows: (t, seen) => { | |
| 429 | const burnt = t.events.flatMap((e) => (e.type === 'blockDestroyed' ? [e.pos] : [])) | |
| 430 | return seen.picked.has('pierce') && explosions(t).some((e) => | |
| 431 | burnt.some((far) => steps(far, e.pos) === 2 && burnt.some((near) => steps(near, e.pos) === 1 && steps(near, far) === 1))) | |
| 432 | }, | |
fa3e81a7 2026-10-24 Pavel Safronov | 433 | }, |
| 434 | power: { | |
d3e4ed26 2026-10-25 Pavel Safronov | 435 | caption: 'Power: the charge button puts down bombs whose flame reaches the walls.', |
fa3e81a7 2026-10-24 Pavel Safronov | 436 | enemies: ['blob', 'drifter'], |
36c5d6a2 2026-10-24 Pavel Safronov | 437 | prepare: dropping('power', 1), |
fa3e81a7 2026-10-24 Pavel Safronov | 438 | shows: (t, seen) => seen.picked.has('power') && kills(t).some((k) => explosions(t).some((e) => steps(e.pos, k.pos) >= 3)), |
| 439 | }, | |
| 440 | nuke: { | |
d3e4ed26 2026-10-25 Pavel Safronov | 441 | caption: 'Nuke: the charge button puts down bombs that burn every tile 2 steps around, round corners too.', |
fa3e81a7 2026-10-24 Pavel Safronov | 442 | enemies: ['blob', 'seeker'], |
36c5d6a2 2026-10-24 Pavel Safronov | 443 | prepare: dropping('nuke', 1), |
fa3e81a7 2026-10-24 Pavel Safronov | 444 | // Killed off the bomb's row and column: only a Nuke reaches there. |
| 445 | shows: (t, seen) => seen.picked.has('nuke') && kills(t).some((k) => explosions(t).some((e) => e.pos.x !== k.pos.x && e.pos.y !== k.pos.y)), | |
| 446 | }, | |
5f066960 2026-10-26 Pavel Safronov | 447 | acid: { |
| 448 | caption: 'Acid: the charge button puts down a bomb whose flame cracks pillars. Blast a cracked one and the ceiling round it falls.', | |
| 449 | enemies: ['blob', 'drifter'], | |
| 450 | prepare: (s, rng) => ((s.stats.fire = 2), dropping('acid', 1)(s, rng)), | |
| 451 | shows: (t, seen) => seen.picked.has('acid') && t.events.some((e) => e.type === 'pillarCracked'), | |
| 452 | }, | |
b8849d36 2026-11-01 Pavel Safronov | 453 | rubble: { |
| 454 | caption: `Rubble: the next blast breaks a cracked pillar, and ${COLLAPSE_TURNS} turns later the ceiling round it comes down for good.`, | |
| 455 | enemies: ['blob'], | |
| 456 | maxSteps: 12, | |
| 457 | tail: 2, | |
| 458 | prepare: crackedPillar, | |
| 459 | shows: (t) => t.events.some((e) => e.type === 'ceilingFell'), | |
| 460 | }, | |
fa3e81a7 2026-10-24 Pavel Safronov | 461 | shield: { |
5f066960 2026-10-26 Pavel Safronov | 462 | caption: 'Shield: while you carry it, it absorbs the next hit.', |
fa3e81a7 2026-10-24 Pavel Safronov | 463 | enemies: ['seeker', 'seeker'], |
| 464 | prepare: dropping('shield'), | |
| 465 | shows: (t) => t.events.some((e) => e.type === 'shieldBroke'), | |
| 466 | }, | |
| 467 | freeze: { | |
d3e4ed26 2026-10-25 Pavel Safronov | 468 | caption: 'Freeze: press the charge button and every enemy stops in its tracks. Then bomb them.', |
92cc4ee3 2026-11-02 Pavel Safronov | 469 | padCaption: 'Freeze: press {charge} and every enemy stops in its tracks. Then bomb them.', |
| 470 | keyCaption: 'Freeze: press {V} twice (the second confirms) and every enemy stops in its tracks. Then bomb them.', | |
fa3e81a7 2026-10-24 Pavel Safronov | 471 | enemies: ['seeker', 'blob', 'blob'], |
| 472 | prepare: dropping('freeze'), | |
| 473 | shows: (t, seen) => seen.picked.has('freeze') && kills(t).length > 0 && t.prev.frozen > 0, | |
| 474 | }, | |
| 475 | decoy: { | |
d3e4ed26 2026-10-25 Pavel Safronov | 476 | caption: 'Decoy: press the charge button and put down a copy of you that Seekers chase instead.', |
92cc4ee3 2026-11-02 Pavel Safronov | 477 | padCaption: 'Decoy: press {charge} and put down a copy of you that Seekers chase instead.', |
| 478 | keyCaption: 'Decoy: {V} and {arrows} (or the {shift} cursor, then {V}) put down a copy of you that Seekers chase instead.', | |
fa3e81a7 2026-10-24 Pavel Safronov | 479 | enemies: ['seeker', 'seeker'], |
| 480 | prepare: dropping('decoy'), | |
9ed991c9 2026-11-03 Pavel Safronov | 481 | steer: fetchAndUse('decoy'), |
fa3e81a7 2026-10-24 Pavel Safronov | 482 | shows: (t, seen) => seen.picked.has('decoy') && kills(t).length > 0 && !!t.prev.decoy, |
| 483 | }, | |
0bfdc8e6 2026-10-25 Pavel Safronov | 484 | // One charge at a time (user question, 2026-10-25: "is there already a demo for the charge |
| 485 | // swap?"): carrying a Pierce, the player picks up a Nuke, and the finger takes it. | |
| 486 | chargeSwap: { | |
| 487 | caption: 'You carry one charge. Picking up another asks: keep yours, or take the new one. The other is sold for coins.', | |
| 488 | enemies: ['blob'], | |
| 489 | prepare: (s, rng) => ((s.charge = { kind: 'pierce', left: 3 }), dropNear(s, rng, 'nuke', 2)), | |
| 490 | choice: true, | |
| 491 | steer: (s) => (s.chargeOffer ? { action: { id: 'swapCharge' }, gesture: { kind: 'choice', swap: true } } : null), | |
| 492 | shows: (t) => t.action.id === 'swapCharge', | |
| 493 | }, | |
d5e3ad4d 2026-10-24 Pavel Safronov | 494 | // The exit (user request, 2026-10-23: "demos end naturally when the player walks into an open door"). |
| 495 | exit: { | |
| 496 | caption: 'A crate hides the exit. Once every enemy is gone it opens: walk in to finish the level.', | |
| 497 | enemies: ['blob'], | |
| 498 | prepare: (s, rng) => placeExit(s, rng, true), | |
| 499 | // Once the exit is open, mostly head for it. | |
| 500 | steer: (s, rng) => { | |
| 501 | if (!exitOpen(s) || rng.next() < 0.2) return null | |
| 502 | const next = playerPath(s, s.exit)?.[0] | |
36c5d6a2 2026-10-24 Pavel Safronov | 503 | return next && isAvailable(s, 'move') && isValidTarget(s, 'move', next) ? playOf(SCENES.exit, s, { id: 'move', target: next }) : null |
d5e3ad4d 2026-10-24 Pavel Safronov | 504 | }, |
| 505 | shows: (t) => t.events.some((e) => e.type === 'levelCleared'), | |
| 506 | }, | |
| 507 | doorBlast: { | |
| 508 | caption: 'Don’t blast the exit once it’s uncovered: three more enemies come out.', | |
| 509 | enemies: ['blob'], | |
| 510 | prepare: (s, rng) => placeExit(s, rng, false), | |
| 511 | shows: (t) => t.events.some((e) => e.type === 'enemiesSpawned'), | |
| 512 | }, | |
32dd53cd 2026-10-25 Pavel Safronov | 513 | // Only for the dialogs' clips (PLAN.md §13c, step 4). |
| 514 | lastEnemy: { | |
| 515 | caption: 'The last enemy gone: the exit opens.', | |
| 516 | enemies: ['blob'], | |
| 517 | prepare: (s, rng) => placeExit(s, rng, false), | |
| 518 | clipOnly: true, | |
| 519 | shows: (t) => t.prev.enemies.length > 0 && t.next.enemies.length === 0 && exitOpen(t.next), | |
| 520 | }, | |
| 521 | crate: { | |
| 522 | caption: 'A blast breaks a crate, and what it hid is uncovered.', | |
| 523 | enemies: ['blob'], | |
| 524 | prepare: (s, rng) => hideGem(s, rng), | |
| 525 | clipOnly: true, | |
| 526 | shows: (t) => t.events.some((e) => e.type === 'blockDestroyed' && t.next.gems.some((g) => at(g.pos, e.pos))), | |
| 527 | }, | |
ac534e57 2026-10-25 Pavel Safronov | 528 | // The run's modes, for their tag's dialog. |
| 529 | rainbowGem: { | |
| 530 | caption: 'Developer mode: a rainbow gem beside you, offering every power-up and charge.', | |
| 531 | enemies: ['blob'], | |
| 532 | prepare: (s, rng) => { | |
| 533 | s.options = { ...s.options, dev: true } | |
| 534 | const near = tilesAt(s, 1) | |
| 535 | if (near.length) s.gems.push({ pos: near[rng.int(near.length)]!, color: 'rainbow', extra: true }) | |
| 536 | }, | |
| 537 | clipOnly: true, | |
| 538 | shows: (t) => t.events.some((e) => e.type === 'powerUpTaken'), | |
| 539 | }, | |
| 540 | immortal: { | |
| 541 | caption: 'Immortal: nothing can hurt you.', | |
| 542 | enemies: ['seeker', 'blob'], | |
| 543 | prepare: (s) => (s.options = { ...s.options, immortal: true }), | |
| 544 | clipOnly: true, | |
| 545 | shows: (t) => explosions(t).some((e) => e.tiles.some((p) => at(p, t.next.player))) || t.next.enemies.some((e) => at(e.pos, t.next.player)), | |
| 546 | }, | |
32dd53cd 2026-10-25 Pavel Safronov | 547 | pillar: { |
| 548 | caption: 'Flame stops at a pillar.', | |
| 549 | enemies: ['blob'], | |
| 550 | // Fire 2, so the flame would reach past a pillar if it could. | |
| 551 | prepare: (s) => (s.stats.fire = 2), | |
| 552 | clipOnly: true, | |
| 553 | shows: (t) => explosions(t).some((e) => DIR_LIST.some((d) => t.prev.cells[idx(t.prev, { x: e.pos.x + DIRS[d].x, y: e.pos.y + DIRS[d].y })] === 'wall' && !isEdge(t.prev, { x: e.pos.x + DIRS[d].x, y: e.pos.y + DIRS[d].y }))), | |
| 554 | }, | |
6c3fcc31 2026-10-26 Pavel Safronov | 555 | // The board's objects (PLAN.md §19.3; 2026-10-25), each in a room with a few of them by the player. |
| 556 | bush: { | |
| 557 | caption: 'Bushes: step into one to hide. Enemies chasing you head for where they last saw you.', | |
| 558 | enemies: ['hunter', 'seeker'], | |
| 559 | prepare: (s, rng) => placeObjects(s, rng, 'bush', 2, 3), | |
| 560 | shows: (t) => | |
| 561 | t.next.status === 'playing' && t.next.bushes.includes(idx(t.next, t.next.player)) && !t.prev.bushes.includes(idx(t.prev, t.prev.player)), | |
| 562 | }, | |
| 563 | bushFire: { | |
| 564 | caption: 'Bushes burn, and the fire creeps on to the next bush a turn later.', | |
| 565 | enemies: ['blob'], | |
| 566 | prepare: (s, rng) => placeObjects(s, rng, 'bush', 2, 4), | |
| 567 | shows: (t) => t.next.status === 'playing' && t.prev.smoldering.length > 0 && t.events.some((e) => e.type === 'fireSpread'), | |
| 568 | }, | |
| 569 | metal: { | |
| 570 | caption: 'Metal crates: nothing breaks them, and flame stops before them.', | |
| 571 | enemies: ['blob'], | |
| 572 | prepare: (s, rng) => ((s.stats.fire = 2), placeObjects(s, rng, 'metal', 2, 2)), | |
| 573 | // A flame that stopped short of its reach at a metal crate. | |
| 574 | shows: (t) => | |
| 575 | explosions(t).some((e) => | |
| 576 | DIR_LIST.some((d) => { | |
| 577 | let p = e.pos | |
| 578 | while (e.tiles.some((q) => at(q, { x: p.x + DIRS[d].x, y: p.y + DIRS[d].y }))) p = { x: p.x + DIRS[d].x, y: p.y + DIRS[d].y } | |
| 579 | const next = { x: p.x + DIRS[d].x, y: p.y + DIRS[d].y } | |
| 580 | return inBounds(t.prev, next) && t.prev.cells[idx(t.prev, next)] === 'metal' | |
| 581 | }), | |
| 582 | ), | |
| 583 | }, | |
| 584 | oil: { | |
| 585 | caption: 'Oil: safe to walk on, but flame on any of it burns the whole spill at once.', | |
| 586 | enemies: ['blob', 'blob'], | |
| 587 | prepare: (s, rng) => placeObjects(s, rng, 'oil', 2, 4), | |
| 588 | shows: (t) => t.next.status === 'playing' && t.events.some((e) => e.type === 'fireSpread' && e.tiles.length >= 2), | |
| 589 | }, | |
1f0038ac 2026-11-01 Pavel Safronov | 590 | oilSlide: { |
| 591 | caption: 'Oil is slippery: step onto a spill and you slide to its far end. Most enemies slip on it too.', | |
| 592 | enemies: ['blob', 'drifter'], | |
| 593 | prepare: (s, rng) => placeOilLine(s, rng, 4), | |
| 594 | // From the third turn on, step onto the spill along its length when the way is there. | |
| 595 | steer: (s) => (s.turn >= 2 ? playOf(SCENES.oilSlide, s, slipMove(s)) : null), | |
| 596 | shows: (t) => t.events.some((e) => e.type === 'oilSlid'), | |
| 597 | tail: 1, | |
| 598 | }, | |
6c3fcc31 2026-10-26 Pavel Safronov | 599 | spikes: { |
| 600 | caption: 'Spikes: up one turn, down the next. Cross while they are down; enemies never step on them.', | |
| 601 | enemies: ['blob'], | |
| 602 | prepare: (s, rng) => placeObjects(s, rng, 'spikes', 1, 3), | |
| 603 | shows: (t) => t.next.status === 'playing' && moved(t) && t.prev.spikes.some((sp) => sp.cell === idx(t.next, t.next.player)), | |
| 604 | }, | |
| 605 | barrel: { | |
| 606 | caption: 'Oil barrel: flame bursts it, spilling oil, and the next turn it explodes.', | |
| 607 | enemies: ['blob'], | |
| 608 | // Fire 2 and two barrels, so random play bursts one often enough. | |
| 609 | prepare: (s, rng) => ((s.stats.fire = 2), placeObjects(s, rng, 'barrel', 2, 2)), | |
| 610 | shows: (t) => t.next.status === 'playing' && explosions(t).some((e) => t.prev.bombs.some((b) => b.barrel && at(b.pos, e.pos))), | |
| 611 | }, | |
d5e3ad4d 2026-10-24 Pavel Safronov | 612 | // The ways to die. |
| 613 | dieIntoBlast: { | |
| 614 | caption: 'Ouch! Walked straight into a blast. Check the red tiles before you step.', | |
| 615 | enemies: ['blob'], | |
| 616 | dies: true, | |
| 617 | shows: (t) => burnt(t) && moved(t) && !inBlast(t, t.prev.player), | |
| 618 | }, | |
| 619 | dieNotFar: { | |
| 620 | caption: 'Ouch! Not far enough: a blast reaches as far as its fire. Get further, or round a corner.', | |
| 621 | enemies: ['blob'], | |
| 622 | // A long blast, easy to run along and stay in. | |
| 623 | prepare: (s) => (s.stats.fire = 3), | |
| 624 | dies: true, | |
| 625 | shows: (t) => burnt(t) && moved(t) && inBlast(t, t.prev.player), | |
| 626 | }, | |
| 627 | dieBlockedIn: { | |
| 628 | caption: 'Ouch! Never put a bomb in your only way out.', | |
| 629 | enemies: ['blob'], | |
| 630 | dies: true, | |
| 631 | // Nowhere to step that isn't about to burn. | |
| 632 | shows: (t) => { | |
| 633 | if (!burnt(t)) return false | |
| 634 | const danger = predictDanger(t.prev).now | |
| 635 | return ACTIONS.move.validTargets(t.prev).every((p) => danger.has(idx(t.prev, p))) | |
| 636 | }, | |
| 637 | }, | |
| 638 | dieChain: { | |
| 639 | caption: 'Ouch! One bomb set off another, and its blast reached you.', | |
| 640 | enemies: ['blob'], | |
| 641 | prepare: (s) => (s.stats.capacity = 2), | |
| 642 | dies: true, | |
| 643 | // Caught only by a bomb that wasn't due yet: the chain set it off. | |
| 644 | shows: (t) => { | |
| 645 | if (!burnt(t)) return false | |
| 646 | const hits = explosions(t).filter((e) => e.tiles.some((p) => at(p, t.next.player))) | |
| 647 | return hits.length > 0 && hits.every((e) => (t.prev.bombs.find((b) => at(b.pos, e.pos))?.timer ?? 0) > 1) | |
| 648 | }, | |
| 649 | }, | |
| 650 | dieEnemy: { | |
| 651 | caption: 'Ouch! Touching an enemy costs a life. Watch where its arrows go.', | |
| 652 | enemies: ['seeker', 'blob'], | |
| 653 | dies: true, | |
| 654 | shows: (t) => died(t) && !burnt(t) && t.action.id !== 'run', | |
| 655 | }, | |
| 656 | dieDetonator: { | |
| 657 | caption: 'Oops! The Detonator sets a bomb off at once, even with you right next to it.', | |
| 658 | enemies: ['blob'], | |
| 659 | prepare: (s) => ((s.powers.detonate = true), (s.stats.fuse = 6)), | |
| 660 | dies: true, | |
| 661 | shows: (t) => burnt(t) && t.events.some((e) => e.type === 'detonated'), | |
| 662 | }, | |
| 663 | dieRun: { | |
| 664 | caption: 'Oops! Ran two tiles, straight into the enemy in between.', | |
| 665 | enemies: ['seeker', 'seeker', 'blob'], | |
| 666 | prepare: (s) => (s.powers.run = true), | |
| 667 | dies: true, | |
| 668 | shows: (t) => died(t) && !burnt(t) && t.action.id === 'run', | |
| 669 | }, | |
52d68b59 2026-10-24 Pavel Safronov | 670 | } |
| 671 | ||
fa3e81a7 2026-10-24 Pavel Safronov | 672 | export const SCENE_KINDS = Object.keys(SCENES) as SceneKind[] |
36c5d6a2 2026-10-24 Pavel Safronov | 673 | /** The scenes about the controls, in the order the UI shows them, first. */ |
| 674 | export const CONTROL_KINDS: readonly SceneKind[] = ['tapMove', 'holdBomb', 'bombButton', 'wait', 'autoMove'] | |
d5e3ad4d 2026-10-24 Pavel Safronov | 675 | /** The scenes about a way to die, and the rest, for the UI to take turns between. */ |
| 676 | export const DYING_KINDS = SCENE_KINDS.filter((k) => SCENES[k].dies) | |
32dd53cd 2026-10-25 Pavel Safronov | 677 | export const LIVING_KINDS = SCENE_KINDS.filter((k) => !SCENES[k].dies && !SCENES[k].clipOnly && !CONTROL_KINDS.includes(k)) |
fa3e81a7 2026-10-24 Pavel Safronov | 678 | |
52d68b59 2026-10-24 Pavel Safronov | 679 | /** A scene of `kind`, from `seed`; null if no room gave one (rare). */ |
| 680 | export function generateScene(kind: SceneKind, seed: number): Scene | null { | |
8edb5cb9 2026-11-01 Pavel Safronov | 681 | const fixed = SCENES[kind].fixed |
| 682 | if (fixed) return fixed() | |
52d68b59 2026-10-24 Pavel Safronov | 683 | const rng = new Rng(seed) |
| 684 | for (let room = 0; room < ROOMS; room++) { | |
| 685 | const start = makeRoom(kind, rng) | |
| 686 | for (let t = 0; t < TRIES; t++) { | |
36c5d6a2 2026-10-24 Pavel Safronov | 687 | const game = rollout(kind, start, rng) |
| 688 | const scene = game && ending(kind, game) | |
| 689 | if (scene) return scene | |
52d68b59 2026-10-24 Pavel Safronov | 690 | } |
| 691 | } | |
| 692 | return null | |
| 693 | } | |
| 694 | ||
b8849d36 2026-11-01 Pavel Safronov | 695 | /** Replays a scene: its turns, and whether its moment turn shows it (for tests, and `ending`). */ |
36c5d6a2 2026-10-24 Pavel Safronov | 696 | export function replayScene(scene: Scene): { turns: Turn[]; shows: boolean } { |
fa3e81a7 2026-10-24 Pavel Safronov | 697 | let s = scene.start |
36c5d6a2 2026-10-24 Pavel Safronov | 698 | const seen = newSeen() |
| 699 | const turns: Turn[] = [] | |
fa3e81a7 2026-10-24 Pavel Safronov | 700 | let last = false |
36c5d6a2 2026-10-24 Pavel Safronov | 701 | for (const [i, action] of scene.actions.entries()) { |
fa3e81a7 2026-10-24 Pavel Safronov | 702 | const r = step(s, action) |
36c5d6a2 2026-10-24 Pavel Safronov | 703 | const turn = { prev: s, action, gesture: scene.gestures[i]!, events: r.events, next: r.state } |
fa3e81a7 2026-10-24 Pavel Safronov | 704 | remember(turn, seen) |
36c5d6a2 2026-10-24 Pavel Safronov | 705 | turns.push(turn) |
b8849d36 2026-11-01 Pavel Safronov | 706 | if (i === (scene.moment ?? scene.actions.length - 1)) last = SCENES[scene.kind].shows(turn, seen) |
fa3e81a7 2026-10-24 Pavel Safronov | 707 | s = r.state |
| 708 | } | |
36c5d6a2 2026-10-24 Pavel Safronov | 709 | return { turns, shows: last } |
| 710 | } | |
| 711 | ||
b8849d36 2026-11-01 Pavel Safronov | 712 | /** Whether a scene's moment turn shows its moment, replaying it from the start (for tests). */ |
36c5d6a2 2026-10-24 Pavel Safronov | 713 | export function showsAtEnd(scene: Scene): boolean { |
| 714 | return replayScene(scene).shows | |
| 715 | } | |
| 716 | ||
| 717 | /** | |
b8849d36 2026-11-01 Pavel Safronov | 718 | * The scene in a game that shows its moment: the turns up to its moment, 3 to 6 of them (`maxSteps` allows more; as few as do), then `tail` turns of waiting, starting |
36c5d6a2 2026-10-24 Pavel Safronov | 719 | * from a calm moment — no bomb or flame on the board, no drop but those the room started with |
| 720 | * (a coin or charge left by an enemy killed before the scene would be a mystery), auto-move not | |
| 721 | * under way — and still showing the moment when played from there. Null if no such stretch. | |
e3384c6b 2026-10-24 Pavel Safronov | 722 | * The top bar shows during the demo, so the scene starts at turn 0 with no coins, as if fresh; |
| 723 | * with no bomb out, nothing depends on the turn number. | |
36c5d6a2 2026-10-24 Pavel Safronov | 724 | */ |
| 725 | function ending(kind: SceneKind, game: GameTurn[]): Scene | null { | |
| 726 | const room = game[0]!.prev | |
b8849d36 2026-11-01 Pavel Safronov | 727 | const def = SCENES[kind] |
| 728 | for (let n = MIN_STEPS; n <= Math.min(def.maxSteps ?? MAX_STEPS, game.length); n++) { | |
36c5d6a2 2026-10-24 Pavel Safronov | 729 | const first = game[game.length - n]! |
| 730 | const s = first.prev | |
b8849d36 2026-11-01 Pavel Safronov | 731 | if (s.bombs.length || s.burning.length || s.collapsing.length || first.gesture.kind === 'go' || first.walking) continue |
36c5d6a2 2026-10-24 Pavel Safronov | 732 | if (s.drops.some((d) => !room.drops.some((r) => at(r.pos, d.pos) && r.kind === d.kind))) continue |
b8849d36 2026-11-01 Pavel Safronov | 733 | const turns: Play[] = game.slice(-n) |
6c3fcc31 2026-10-26 Pavel Safronov | 734 | // Spikes keep their rhythm across the reset to turn 0. |
| 735 | const spikes = s.spikes.map((sp) => ({ ...sp, phase: (sp.phase + s.turn) % 2 })) | |
b8849d36 2026-11-01 Pavel Safronov | 736 | const scene: Scene = { kind, start: { ...s, turn: 0, coins: 0, spikes }, actions: turns.map((t) => t.action), gestures: turns.map((t) => t.gesture), moment: n - 1 } |
| 737 | if (!showsAtEnd(scene)) continue | |
| 738 | // The aftermath: a few turns of waiting, while that is safe, to see what the moment did. | |
| 739 | let after = game[game.length - 1]!.next | |
| 740 | for (let i = 0; i < (def.tail ?? 0) && after.status === 'playing' && !after.offer; i++) { | |
| 741 | const play = playOf(def, after, { id: 'wait' }) | |
| 742 | if (!play || isDeadly(after, play.action)) break | |
| 743 | scene.actions.push(play.action) | |
| 744 | scene.gestures.push(play.gesture) | |
| 745 | after = step(after, play.action).state | |
| 746 | } | |
| 747 | return scene | |
36c5d6a2 2026-10-24 Pavel Safronov | 748 | } |
| 749 | return null | |
fa3e81a7 2026-10-24 Pavel Safronov | 750 | } |
| 751 | ||
52d68b59 2026-10-24 Pavel Safronov | 752 | /** A small room: walls round it, pillars on every other tile, a few crates; the player and enemies apart. */ |
| 753 | function makeRoom(kind: SceneKind, rng: Rng): GameState { | |
| 754 | const cells: Cell[] = [] | |
| 755 | for (let y = 0; y < HEIGHT; y++) { | |
| 756 | for (let x = 0; x < WIDTH; x++) { | |
| 757 | const edge = x === 0 || y === 0 || x === WIDTH - 1 || y === HEIGHT - 1 | |
| 758 | cells.push(edge || (x % 2 === 0 && y % 2 === 0) ? 'wall' : 'floor') | |
| 759 | } | |
| 760 | } | |
| 761 | const floor = () => cells.flatMap((c, i) => (c === 'floor' ? [{ x: i % WIDTH, y: Math.floor(i / WIDTH) }] : [])) | |
| 762 | const tiles = floor() | |
| 763 | const player = tiles[rng.int(tiles.length)]! | |
| 764 | // Crates on about one free tile in six, never next to the player (room to place a bomb and go). | |
fa3e81a7 2026-10-24 Pavel Safronov | 765 | for (const p of tiles) if (steps(p, player) > 2 && rng.next() < 0.16) cells[p.y * WIDTH + p.x] = 'block' |
52d68b59 2026-10-24 Pavel Safronov | 766 | const s: GameState = { |
00ad7d0a 2026-10-24 Pavel Safronov | 767 | seed: rng.int(2 ** 31), options: { ...DEFAULT_OPTIONS }, level: 1, turn: 0, width: WIDTH, height: HEIGHT, |
52d68b59 2026-10-24 Pavel Safronov | 768 | cells, player: { ...player }, lives: START_LIVES, stats: { ...START_STATS }, bombs: [], status: 'playing', |
e3384c6b 2026-10-24 Pavel Safronov | 769 | enemies: [], nextEnemyId: 0, drops: [], exit: { x: -1, y: -1 }, par: DEMO_PAR, coins: 0, |
8edb5cb9 2026-11-01 Pavel Safronov | 770 | charge: null, chargeOffer: null, waitingGems: [], frozen: 0, decoy: null, burning: [], |
6c3fcc31 2026-10-26 Pavel Safronov | 771 | powers: noPowers(), star: 0, gems: [], offer: null, shop: null, death: null, runTurns: 0, runKills: 0, deaths: 0, crumbling: [], bushes: [], smoldering: [], oil: [], spikes: [], cracked: [], collapsing: [], lastSeen: { ...player }, |
52d68b59 2026-10-24 Pavel Safronov | 772 | aiRng: rng.int(2 ** 31), dropRng: rng.int(2 ** 31), |
| 773 | } | |
fa3e81a7 2026-10-24 Pavel Safronov | 774 | const open = floor().filter((p) => steps(p, player) >= 3) |
| 775 | for (const enemy of SCENES[kind].enemies) { | |
52d68b59 2026-10-24 Pavel Safronov | 776 | if (!open.length) break |
| 777 | const pos = open.splice(rng.int(open.length), 1)[0]! | |
| 778 | s.enemies.push({ id: s.nextEnemyId++, kind: enemy, pos, dir: 'left', plan: [], spawned: false, sheltered: false }) | |
| 779 | } | |
fa3e81a7 2026-10-24 Pavel Safronov | 780 | SCENES[kind].prepare?.(s, rng) |
e3384c6b 2026-10-24 Pavel Safronov | 781 | if (s.exit.x < 0) placeDoor(s, rng) |
52d68b59 2026-10-24 Pavel Safronov | 782 | planIntents(s) |
| 783 | return s | |
| 784 | } | |
| 785 | ||
e3384c6b 2026-10-24 Pavel Safronov | 786 | /** |
| 787 | * Every room has its exit, as every level does (user request, 2026-10-24: captions talk about it, | |
| 788 | * and most rooms had none): mostly under a crate, as in play; one room in six with it already | |
| 789 | * uncovered, still locked while enemies live. Not under the gem's crate, not on a drop, and at | |
| 790 | * least 3 steps from the player. | |
| 791 | */ | |
| 792 | function placeDoor(s: GameState, rng: Rng): void { | |
| 793 | const tiles = (cell: Cell) => | |
| 794 | s.cells.flatMap((c, i) => { | |
| 795 | const p = { x: i % s.width, y: Math.floor(i / s.width) } | |
| 796 | const free = !s.enemies.some((e) => at(e.pos, p)) && !s.drops.some((d) => at(d.pos, p)) && !s.gems.some((g) => at(g.pos, p)) | |
| 797 | return c === cell && free && steps(p, s.player) >= 3 ? [p] : [] | |
| 798 | }) | |
| 799 | const crates = tiles('block') | |
| 800 | const floor = tiles('floor') | |
| 801 | const pick = crates.length && (rng.next() >= 1 / 6 || !floor.length) ? crates : floor | |
| 802 | if (pick.length) s.exit = pick[rng.int(pick.length)]! | |
| 803 | } | |
| 804 | ||
b8849d36 2026-11-01 Pavel Safronov | 805 | /** |
| 806 | * Rubble's room: one pillar already cracked (as Acid leaves it), the player a few steps from it with | |
| 807 | * a blast long enough to reach it from beside it. | |
| 808 | */ | |
| 809 | function crackedPillar(s: GameState, rng: Rng): void { | |
| 810 | const pillars = s.cells.flatMap((c, i) => { | |
| 811 | const p = { x: i % s.width, y: Math.floor(i / s.width) } | |
| 812 | return c === 'wall' && !(p.x === 0 || p.y === 0 || p.x === s.width - 1 || p.y === s.height - 1) ? [i] : [] | |
| 813 | }) | |
| 814 | const pillar = pillars[rng.int(pillars.length)]! | |
| 815 | s.cracked.push(pillar) | |
| 816 | const at2 = { x: pillar % s.width, y: Math.floor(pillar / s.width) } | |
| 817 | const near = s.cells.flatMap((c, i) => { | |
| 818 | const p = { x: i % s.width, y: Math.floor(i / s.width) } | |
| 819 | const d = steps(p, at2) | |
| 820 | return c === 'floor' && d >= 2 && d <= 3 && !s.enemies.some((e) => at(e.pos, p)) ? [p] : [] | |
| 821 | }) | |
| 822 | if (near.length) s.player = { ...near[rng.int(near.length)]! } | |
| 823 | s.lastSeen = { ...s.player } | |
| 824 | s.stats.fire = 2 | |
| 825 | } | |
| 826 | ||
fa3e81a7 2026-10-24 Pavel Safronov | 827 | /** Free floor tiles `n` steps from the player, with no enemy. */ |
| 828 | function tilesAt(s: GameState, n: number): Vec[] { | |
| 829 | return s.cells.flatMap((c, i) => { | |
52d68b59 2026-10-24 Pavel Safronov | 830 | const p = { x: i % s.width, y: Math.floor(i / s.width) } |
fa3e81a7 2026-10-24 Pavel Safronov | 831 | return c === 'floor' && steps(p, s.player) === n && !s.enemies.some((e) => at(e.pos, p)) ? [p] : [] |
52d68b59 2026-10-24 Pavel Safronov | 832 | }) |
fa3e81a7 2026-10-24 Pavel Safronov | 833 | } |
| 834 | ||
36c5d6a2 2026-10-24 Pavel Safronov | 835 | /** |
| 836 | * Pierce's room: in a straight line from the player, Pierce on the next tile, a free one for the | |
| 837 | * bomb, then two crates; or, with no room for that, Pierce on any tile next to the player. | |
| 838 | */ | |
| 839 | function crateRow(s: GameState, rng: Rng): void { | |
| 840 | const dirs = DIR_LIST.map((d) => DIRS[d]).filter((d) => | |
| 841 | [1, 2, 3, 4].every((n) => { | |
| 842 | const p = { x: s.player.x + n * d.x, y: s.player.y + n * d.y } | |
| 843 | return inBounds(s, p) && s.cells[idx(s, p)] !== 'wall' && !s.enemies.some((e) => at(e.pos, p)) | |
| 844 | }), | |
| 845 | ) | |
| 846 | if (!dirs.length) return dropNear(s, rng, 'pierce', 1) | |
| 847 | const d = dirs[rng.int(dirs.length)]! | |
| 848 | const along = (n: number) => ({ x: s.player.x + n * d.x, y: s.player.y + n * d.y }) | |
| 849 | for (const n of [1, 2]) s.cells[idx(s, along(n))] = 'floor' | |
| 850 | for (const n of [3, 4]) s.cells[idx(s, along(n))] = 'block' | |
| 851 | s.drops.push({ pos: along(1), kind: 'pierce' }) | |
fa3e81a7 2026-10-24 Pavel Safronov | 852 | } |
| 853 | ||
d5e3ad4d 2026-10-24 Pavel Safronov | 854 | /** The exit two steps from the player: under a crate, or uncovered (but locked while enemies live). */ |
| 855 | function placeExit(s: GameState, rng: Rng, covered: boolean): void { | |
| 856 | const near = tilesAt(s, 2) | |
| 857 | if (!near.length) return | |
| 858 | s.exit = near[rng.int(near.length)]! | |
| 859 | if (covered) s.cells[idx(s, s.exit)] = 'block' | |
| 860 | } | |
| 861 | ||
6c3fcc31 2026-10-26 Pavel Safronov | 862 | /** |
| 863 | * `count` of a board object round a free floor tile `n` steps from the player: bushes and oil in a | |
| 864 | * touching clump, spikes, metal crates and barrels on tiles of their own (never next to the player, | |
| 865 | * for crates and barrels, so there is room to move). | |
| 866 | */ | |
| 867 | function placeObjects(s: GameState, rng: Rng, kind: 'bush' | 'oil' | 'spikes' | 'metal' | 'barrel', n: number, count: number): void { | |
| 868 | const near = tilesAt(s, n) | |
| 869 | if (!near.length) return | |
| 870 | const placed: Vec[] = [near[rng.int(near.length)]!] | |
| 871 | const free = (p: Vec) => | |
| 872 | inBounds(s, p) && s.cells[idx(s, p)] === 'floor' && !at(p, s.player) && !placed.some((q) => at(q, p)) && !s.enemies.some((e) => at(e.pos, p)) | |
| 873 | while (placed.length < count) { | |
| 874 | const from = kind === 'bush' || kind === 'oil' ? placed : tilesAt(s, n).concat(tilesAt(s, n + 1)) | |
| 875 | const next = from.flatMap((p) => (kind === 'bush' || kind === 'oil' ? DIR_LIST.map((d) => ({ x: p.x + DIRS[d].x, y: p.y + DIRS[d].y })) : [p])).filter(free) | |
| 876 | if (!next.length) break | |
| 877 | placed.push(next[rng.int(next.length)]!) | |
| 878 | } | |
| 879 | for (const p of placed) { | |
| 880 | const i = idx(s, p) | |
| 881 | if (kind === 'bush') s.bushes.push(i) | |
| 882 | else if (kind === 'oil') s.oil.push(i) | |
| 883 | else if (kind === 'spikes') s.spikes.push({ cell: i, phase: rng.int(2) }) | |
| 884 | else if (steps(p, s.player) > 1) s.cells[i] = kind | |
| 885 | } | |
| 886 | } | |
| 887 | ||
1f0038ac 2026-11-01 Pavel Safronov | 888 | /** |
| 889 | * An oil spill in a straight line of `len` tiles, 2 steps from the player at its nearer end (the | |
| 890 | * tiles are made floor); or, with no room, a plain clump. A slide is only seen along a spill. | |
| 891 | */ | |
| 892 | function placeOilLine(s: GameState, rng: Rng, len: number): void { | |
| 893 | const lines = tilesAt(s, 2).flatMap((from) => | |
| 894 | DIR_LIST.map((d) => DIRS[d]).flatMap((d) => { | |
| 895 | const line = Array.from({ length: len }, (_, n) => ({ x: from.x + n * d.x, y: from.y + n * d.y })) | |
| 896 | 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))) | |
| 897 | return ok ? [line] : [] | |
| 898 | }), | |
| 899 | ) | |
| 900 | if (!lines.length) return placeObjects(s, rng, 'oil', 2, len) | |
| 901 | for (const p of lines[rng.int(lines.length)]!) { | |
| 902 | s.cells[idx(s, p)] = 'floor' | |
| 903 | s.oil.push(idx(s, p)) | |
| 904 | } | |
| 905 | } | |
| 906 | ||
| 907 | /** A step onto an oil tile that has oil behind it, the way the step goes: it slides on. Null if none. */ | |
| 908 | function slipMove(s: GameState): Action | null { | |
| 909 | for (const d of DIR_LIST) { | |
| 910 | const first = { x: s.player.x + DIRS[d].x, y: s.player.y + DIRS[d].y } | |
| 911 | const second = { x: first.x + DIRS[d].x, y: first.y + DIRS[d].y } | |
| 912 | if (playerCanEnter(s, first) && s.oil.includes(idx(s, first)) && inBounds(s, second) && s.oil.includes(idx(s, second))) return { id: 'move', target: first } | |
| 913 | } | |
| 914 | return null | |
| 915 | } | |
| 916 | ||
36c5d6a2 2026-10-24 Pavel Safronov | 917 | /** Put a charge `n` steps from the player. */ |
| 918 | function dropNear(s: GameState, rng: Rng, kind: ChargeId, n = 2): void { | |
| 919 | const near = tilesAt(s, n) | |
52d68b59 2026-10-24 Pavel Safronov | 920 | if (near.length) s.drops.push({ pos: near[rng.int(near.length)]!, kind }) |
| 921 | } | |
| 922 | ||
fa3e81a7 2026-10-24 Pavel Safronov | 923 | /** Hide the level's gem under a crate two steps from the player. */ |
| 924 | function hideGem(s: GameState, rng: Rng): void { | |
| 925 | const near = tilesAt(s, 2) | |
| 926 | if (!near.length) return | |
| 927 | const pos = near[rng.int(near.length)]! | |
| 928 | s.cells[idx(s, pos)] = 'block' | |
39c26813 2026-10-24 Pavel Safronov | 929 | s.gems.push({ pos, color: (['blue', 'green', 'red', 'yellow'] as const)[rng.int(4)]! }) |
fa3e81a7 2026-10-24 Pavel Safronov | 930 | } |
| 931 | ||
36c5d6a2 2026-10-24 Pavel Safronov | 932 | function newSeen(): Seen { |
| 933 | return { picked: new Set(), used: new Map(), walk: null } | |
| 934 | } | |
| 935 | ||
fa3e81a7 2026-10-24 Pavel Safronov | 936 | function remember(t: Turn, seen: Seen): void { |
36c5d6a2 2026-10-24 Pavel Safronov | 937 | seen.used.set(t.action.id as ActionId, (seen.used.get(t.action.id as ActionId) ?? 0) + 1) |
| 938 | if (t.gesture.kind === 'walk') seen.walk = t.gesture.tile | |
fa3e81a7 2026-10-24 Pavel Safronov | 939 | for (const e of t.events) if (e.type === 'pickedUp' && e.drop.kind !== 'coins') seen.picked.add(e.drop.kind) |
| 940 | } | |
| 941 | ||
36c5d6a2 2026-10-24 Pavel Safronov | 942 | /** A turn of a random game; `walking`: auto-move had a destination before it. */ |
| 943 | type GameTurn = Turn & { walking: boolean } | |
| 944 | ||
| 945 | /** One random game from `start`; its turns if it shows the scene, otherwise null. */ | |
| 946 | function rollout(kind: SceneKind, start: GameState, rng: Rng): GameTurn[] | null { | |
52d68b59 2026-10-24 Pavel Safronov | 947 | let s = start |
36c5d6a2 2026-10-24 Pavel Safronov | 948 | const turns: GameTurn[] = [] |
| 949 | const seen = newSeen() | |
| 950 | const walking: Walking = { to: null } | |
| 951 | const def = SCENES[kind] | |
fa3e81a7 2026-10-24 Pavel Safronov | 952 | for (let n = 0; n < MAX_TURNS && s.status === 'playing' && !s.offer; n++) { |
33a0256b 2026-10-24 Pavel Safronov | 953 | // A random turn (or the scene's nudge) that doesn't get the player killed and that a sensible |
| 954 | // player might take, a few goes at it — unless dying is what the scene is about, and this is | |
| 955 | // the way it shows. Only turns a gesture can play. | |
36c5d6a2 2026-10-24 Pavel Safronov | 956 | let turn: GameTurn | null = null |
| 957 | const was = walking.to | |
33a0256b 2026-10-24 Pavel Safronov | 958 | for (let go = 0; go < 6 && !turn; go++) { |
36c5d6a2 2026-10-24 Pavel Safronov | 959 | walking.to = was |
| 960 | const play = def.steer?.(s, rng, walking) ?? playOf(def, s, randomAction(s, rng)) | |
| 961 | if (!play) continue | |
| 962 | const r = step(s, play.action) | |
0bfdc8e6 2026-10-25 Pavel Safronov | 963 | // A second charge picked up asks to sell or swap: only in the scene about that. |
| 964 | if (r.state.chargeOffer && !def.choice) continue | |
36c5d6a2 2026-10-24 Pavel Safronov | 965 | const t = { prev: s, ...play, events: r.events, next: r.state, walking: was !== null } |
d5e3ad4d 2026-10-24 Pavel Safronov | 966 | const alive = r.state.status === 'playing' || r.state.status === 'cleared' || r.state.status === 'won' |
33a0256b 2026-10-24 Pavel Safronov | 967 | if ((alive && (def.dies || reasonable(kind, t))) || (def.dies && def.shows(t, seen))) turn = t |
52d68b59 2026-10-24 Pavel Safronov | 968 | } |
fa3e81a7 2026-10-24 Pavel Safronov | 969 | if (!turn) return null |
36c5d6a2 2026-10-24 Pavel Safronov | 970 | turns.push(turn) |
fa3e81a7 2026-10-24 Pavel Safronov | 971 | remember(turn, seen) |
36c5d6a2 2026-10-24 Pavel Safronov | 972 | if (walking.to && at(turn.next.player, walking.to)) walking.to = null |
| 973 | if (def.shows(turn, seen) && !(def.dies && turn.next.status === 'playing')) return turns | |
fa3e81a7 2026-10-24 Pavel Safronov | 974 | s = turn.next |
52d68b59 2026-10-24 Pavel Safronov | 975 | } |
| 976 | return null | |
| 977 | } | |
| 978 | ||
33a0256b 2026-10-24 Pavel Safronov | 979 | /** |
| 980 | * Whether a turn is one a sensible player might take (user request, 2026-10-24: a scene put a | |
| 981 | * bomb on the exit). Not asked in the scenes about dying, whose point is a mistake. | |
| 982 | * - No flame on an uncovered exit or gem, which lets enemies out — unless that is the scene. | |
| 983 | * - A bomb (placed or thrown) only where its blast reaches a crate, an enemy, a tile an enemy is | |
| 984 | * going to, or another bomb (to set it off): never one that does nothing. | |
| 985 | */ | |
| 986 | function reasonable(kind: SceneKind, t: Turn): boolean { | |
| 987 | const punishing = kind !== 'doorBlast' | |
| 988 | if (punishing && t.events.some((e) => e.type === 'enemiesSpawned')) return false | |
d3e4ed26 2026-10-25 Pavel Safronov | 989 | const placing = t.action.id === 'bomb' || t.action.id === 'throw' || t.action.id === 'rpg' || t.action.id === 'charged' |
7f242d36 2026-10-25 Pavel Safronov | 990 | if (!placing || !('target' in t.action) || !t.action.target) return true |
33a0256b 2026-10-24 Pavel Safronov | 991 | const target = t.action.target |
| 992 | const bomb = t.next.bombs.find((b) => at(b.pos, target)) | |
| 993 | if (!bomb) return true | |
| 994 | const tiles = blastTiles(t.next, bomb) | |
| 995 | const s = t.prev | |
| 996 | const uncovered = (p: Vec) => s.cells[idx(s, p)] === 'floor' && (at(p, s.exit) || s.gems.some((g) => at(g.pos, p))) | |
| 997 | if (punishing && tiles.some(uncovered)) return false | |
| 998 | return tiles.some( | |
b8849d36 2026-11-01 Pavel Safronov | 999 | (p) => s.cells[idx(s, p)] === 'block' || s.cracked.includes(idx(s, p)) || s.bombs.some((b) => at(b.pos, p)) || s.enemies.some((e) => at(e.pos, p) || e.plan.some((q) => at(q, p))), |
33a0256b 2026-10-24 Pavel Safronov | 1000 | ) |
| 1001 | } | |
| 1002 | ||
36c5d6a2 2026-10-24 Pavel Safronov | 1003 | /** |
| 1004 | * The gesture that plays `action` in a scene, or null if none does (a step onto a bomb with Bomb | |
7f242d36 2026-10-25 Pavel Safronov | 1005 | * Pass: a tap there kicks it, or means nothing). A step, a diagonal step, a slide, a kick or a detonation is |
| 1006 | * a tap on the tile (a slide too); a bomb is a long press, or the Bomb button and a tap in the scene about it; | |
36c5d6a2 2026-10-24 Pavel Safronov | 1007 | * Wait is its button; Run, Teleport and Throw are their button (Move or Bomb), then the tile. |
| 1008 | */ | |
| 1009 | function gestureFor(def: SceneDef, s: GameState, action: Action): Gesture | null { | |
| 1010 | if (action.id === 'wait') return { kind: 'button', button: 'wait' } | |
| 1011 | if (!('target' in action) || !action.target) return null | |
| 1012 | const tile = action.target | |
e3d8dc49 2026-10-27 Pavel Safronov | 1013 | // A long press never puts down a bomb that costs a life (main.ts `startPress`): its button does. |
| 1014 | if (action.id === 'bomb' && !def.bombButton && !isDeadly(s, action)) return { kind: 'hold', tile } | |
36c5d6a2 2026-10-24 Pavel Safronov | 1015 | if (TAPPED.has(action.id) && tapMeaning(s, tile) === action.id) return { kind: 'tap', tile } |
d3e4ed26 2026-10-25 Pavel Safronov | 1016 | const button: ButtonId = membersOf('move').includes(action.id) ? 'move' : membersOf('charged').includes(action.id) ? 'charged' : 'bomb' |
36c5d6a2 2026-10-24 Pavel Safronov | 1017 | return resolvePick(s, button, tile) === action.id ? { kind: 'button', button, tile } : null |
| 1018 | } | |
| 1019 | ||
7f242d36 2026-10-25 Pavel Safronov | 1020 | const TAPPED: ReadonlySet<ActionId> = new Set(['move', 'diagonal', 'kick', 'detonate', 'slide']) |
36c5d6a2 2026-10-24 Pavel Safronov | 1021 | |
| 1022 | /** An action and the gesture that plays it in this scene, or null if no gesture does. */ | |
| 1023 | function playOf(def: SceneDef, s: GameState, action: Action | null): Play | null { | |
| 1024 | const gesture = action && gestureFor(def, s, action) | |
| 1025 | return gesture ? { action: action!, gesture } : null | |
| 1026 | } | |
| 1027 | ||
fa3e81a7 2026-10-24 Pavel Safronov | 1028 | /** Weights for a random action: mostly moving, bombing now and then, sometimes waiting; abilities when held. */ |
7f242d36 2026-10-25 Pavel Safronov | 1029 | const WEIGHTS: Partial<Record<ActionId, number>> = { |
| 1030 | move: 5, bomb: 2, wait: 1, detonate: 3, run: 2, kick: 3, throw: 2, teleport: 2, diagonal: 2, rpg: 2, icbm: 3, slide: 3, | |
5f066960 2026-10-26 Pavel Safronov | 1031 | charged: 3, freeze: 2, decoy: 2, |
7f242d36 2026-10-25 Pavel Safronov | 1032 | } |
52d68b59 2026-10-24 Pavel Safronov | 1033 | |
| 1034 | function randomAction(s: GameState, rng: Rng): Action { | |
| 1035 | const choices = (Object.keys(WEIGHTS) as ActionId[]).filter((id) => isAvailable(s, id)) | |
| 1036 | let r = rng.next() * choices.reduce((sum, id) => sum + WEIGHTS[id]!, 0) | |
| 1037 | const id = choices.find((c) => (r -= WEIGHTS[c]!) < 0) ?? 'wait' | |
| 1038 | if (!ACTIONS[id].needsTarget) return { id } | |
36c5d6a2 2026-10-24 Pavel Safronov | 1039 | return randomTarget(s, rng, id)! |
| 1040 | } | |
| 1041 | ||
| 1042 | /** `id` aimed at one of its tiles, at random; null if it has none. */ | |
| 1043 | function randomTarget(s: GameState, rng: Rng, id: ActionId): Action | null { | |
52d68b59 2026-10-24 Pavel Safronov | 1044 | const targets = ACTIONS[id].validTargets(s) |
36c5d6a2 2026-10-24 Pavel Safronov | 1045 | return targets.length ? { id, target: targets[rng.int(targets.length)]! } : null |
52d68b59 2026-10-24 Pavel Safronov | 1046 | } |