explodorama / files / src / sim / explosions.ts
explosions.ts
52d68b59 2026-10-24 Pavel Safronov | 1 | import { NUKE_RANGE } from './actions' |
5f066960 2026-10-26 Pavel Safronov | 2 | import { add, bombAt, cellAt, eq, idx, inBounds } from './state' |
| 3 | import { DIRS, DIR_LIST, isSolid, type Bomb, type GameEvent, type GameState, type Vec } from './types' | |
5551eb37 2026-10-20 Pavel Safronov | 4 | |
| 5 | /** | |
| 6 | * The tiles a bomb's flame reaches: its own tile, then up to `fire` tiles in each direction. | |
5f066960 2026-10-26 Pavel Safronov | 7 | * Flame stops before a wall, metal crate or rubble, and stops *on* a crate, an oil barrel or |
| 8 | * another bomb (burning it). A Pierce bomb's flame carries on through crates, not barrels. A | |
| 9 | * Nuke bomb also burns every tile within NUKE_RANGE steps (across plus down), through anything, | |
e3d8dc49 2026-10-27 Pavel Safronov | 10 | * except what is solid, which still stands. So does an Acid bomb (user request, 2026-10-26: an |
| 11 | * area weapon like the Nuke; until then it burned a plain cross). | |
5551eb37 2026-10-20 Pavel Safronov | 12 | */ |
e3d8dc49 2026-10-27 Pavel Safronov | 13 | export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'pierce' | 'nuke' | 'acid'>): Vec[] { |
5551eb37 2026-10-20 Pavel Safronov | 14 | const tiles = [bomb.pos] |
| 15 | for (const d of DIR_LIST) { | |
| 16 | let p = bomb.pos | |
| 17 | for (let i = 0; i < bomb.fire; i++) { | |
| 18 | p = add(p, DIRS[d]) | |
| 19 | const cell = cellAt(s, p) | |
5f066960 2026-10-26 Pavel Safronov | 20 | if (isSolid(cell)) break |
5551eb37 2026-10-20 Pavel Safronov | 21 | tiles.push(p) |
5f066960 2026-10-26 Pavel Safronov | 22 | if ((cell === 'block' && !bomb.pierce) || cell === 'barrel' || bombAt(s, p)) break |
52d68b59 2026-10-24 Pavel Safronov | 23 | } |
| 24 | } | |
e3d8dc49 2026-10-27 Pavel Safronov | 25 | if (bomb.nuke || bomb.acid) { |
52d68b59 2026-10-24 Pavel Safronov | 26 | for (let dy = -NUKE_RANGE; dy <= NUKE_RANGE; dy++) { |
| 27 | for (let dx = -NUKE_RANGE; dx <= NUKE_RANGE; dx++) { | |
| 28 | const p = add(bomb.pos, { x: dx, y: dy }) | |
5f066960 2026-10-26 Pavel Safronov | 29 | if (Math.abs(dx) + Math.abs(dy) > NUKE_RANGE || isSolid(cellAt(s, p))) continue |
52d68b59 2026-10-24 Pavel Safronov | 30 | if (!tiles.some((t) => t.x === p.x && t.y === p.y)) tiles.push(p) |
| 31 | } | |
5551eb37 2026-10-20 Pavel Safronov | 32 | } |
| 33 | } | |
| 34 | return tiles | |
| 35 | } | |
| 36 | ||
5f066960 2026-10-26 Pavel Safronov | 37 | /** A pillar: a wall inside the board, not its outer wall. */ |
| 38 | export function isPillar(s: GameState, p: Vec): boolean { | |
| 39 | return cellAt(s, p) === 'wall' && p.x > 0 && p.y > 0 && p.x < s.width - 1 && p.y < s.height - 1 | |
| 40 | } | |
| 41 | ||
e3d8dc49 2026-10-27 Pavel Safronov | 42 | /** |
| 43 | * The pillars a bomb's flame runs into: where a flame, still within reach, stops at one. An Acid | |
| 44 | * bomb's also reaches every pillar within NUKE_RANGE steps, through anything (user request, | |
| 45 | * 2026-10-26: one diagonal to it was left untouched). | |
| 46 | */ | |
| 47 | export function pillarsReached(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'pierce' | 'acid'>): Vec[] { | |
5f066960 2026-10-26 Pavel Safronov | 48 | const pillars: Vec[] = [] |
e3d8dc49 2026-10-27 Pavel Safronov | 49 | if (bomb.acid) { |
| 50 | for (let dy = -NUKE_RANGE; dy <= NUKE_RANGE; dy++) { | |
| 51 | for (let dx = -NUKE_RANGE; dx <= NUKE_RANGE; dx++) { | |
| 52 | const p = add(bomb.pos, { x: dx, y: dy }) | |
| 53 | if (Math.abs(dx) + Math.abs(dy) <= NUKE_RANGE && isPillar(s, p)) pillars.push(p) | |
| 54 | } | |
| 55 | } | |
| 56 | } | |
5f066960 2026-10-26 Pavel Safronov | 57 | for (const d of DIR_LIST) { |
| 58 | let p = bomb.pos | |
| 59 | for (let i = 0; i < bomb.fire; i++) { | |
| 60 | p = add(p, DIRS[d]) | |
| 61 | const cell = cellAt(s, p) | |
e3d8dc49 2026-10-27 Pavel Safronov | 62 | if (isPillar(s, p) && !pillars.some((q) => eq(q, p))) pillars.push(p) |
5f066960 2026-10-26 Pavel Safronov | 63 | if (isSolid(cell) || (cell === 'block' && !bomb.pierce) || cell === 'barrel' || bombAt(s, p)) break |
| 64 | } | |
| 65 | } | |
| 66 | return pillars | |
| 67 | } | |
| 68 | ||
| 69 | /** Turns from a cracked pillar breaking to the ceiling round it falling (user's choice, 2026-10-25). */ | |
| 70 | export const COLLAPSE_TURNS = 4 | |
| 71 | /** An oil barrel's blast, the turn after flame reaches it. */ | |
| 72 | export const BARREL_FIRE = 2 | |
| 73 | ||
5551eb37 2026-10-20 Pavel Safronov | 74 | /** |
| 75 | * Explode every bomb whose timer is at 0 or below, and every bomb their flame reaches, in a | |
5f066960 2026-10-26 Pavel Safronov | 76 | * chain, all in this one call. Crates are destroyed only once the whole chain is done, so a crate |
| 77 | * stops every flame that reaches it this turn. Returns the burned tiles' indices. | |
| 78 | * | |
| 79 | * Since 2026-10-25 (PLAN.md §19.3, §19.4) flame also spreads without a blast, in the same chain: | |
| 80 | * flame on any tile of an oil spill burns the whole spill at once, setting off any bomb on it; | |
| 81 | * bushes smouldering since last turn (next to one that burned) catch now. A burned bush is gone, | |
| 82 | * and the bushes next to it smoulder, to catch next turn. An oil barrel reached bursts: oil on its | |
| 83 | * tile and the floor round it, and a blast of BARREL_FIRE the next turn. Acid flame cracks each | |
| 84 | * pillar it reaches; any flame reaching a pillar cracked before this turn breaks it, and the | |
| 85 | * ceiling round it falls COLLAPSE_TURNS turns later. | |
5551eb37 2026-10-20 Pavel Safronov | 86 | */ |
| 87 | export function detonate(s: GameState, events: GameEvent[]): Set<number> { | |
| 88 | const burned = new Set<number>() | |
| 89 | const hitBlocks = new Set<number>() | |
5f066960 2026-10-26 Pavel Safronov | 90 | const hitBarrels = new Set<number>() |
| 91 | const crackedBefore = new Set(s.cracked) | |
| 92 | const cracks = new Set<number>() | |
| 93 | const breaks = new Set<number>() | |
| 94 | const spread: Vec[] = [] | |
| 95 | const at = (i: number): Vec => ({ x: i % s.width, y: Math.floor(i / s.width) }) | |
| 96 | const burn = (i: number): void => { | |
| 97 | burned.add(i) | |
| 98 | if (s.cells[i] === 'block') hitBlocks.add(i) | |
| 99 | if (s.cells[i] === 'barrel') hitBarrels.add(i) | |
| 100 | const other = bombAt(s, at(i)) | |
| 101 | if (other) other.timer = 0 | |
| 102 | } | |
| 103 | // Flame without a blast: on a tile not already burning this turn. | |
| 104 | const catchFire = (i: number): void => { | |
| 105 | if (burned.has(i)) return | |
| 106 | burn(i) | |
| 107 | spread.push(at(i)) | |
| 108 | } | |
| 109 | for (const i of s.smoldering) if (s.bushes.includes(i)) catchFire(i) | |
| 110 | s.smoldering = [] | |
| 111 | const oil = new Set(s.oil) | |
5551eb37 2026-10-20 Pavel Safronov | 112 | let bomb: Bomb | undefined |
5f066960 2026-10-26 Pavel Safronov | 113 | for (;;) { |
| 114 | while ((bomb = s.bombs.find((b) => b.timer <= 0))) { | |
| 115 | s.bombs.splice(s.bombs.indexOf(bomb), 1) | |
| 116 | const tiles = blastTiles(s, bomb) | |
| 117 | for (const t of tiles) burn(idx(s, t)) | |
| 118 | for (const p of pillarsReached(s, bomb)) { | |
| 119 | const i = idx(s, p) | |
| 120 | if (crackedBefore.has(i)) breaks.add(i) | |
| 121 | else if (bomb.acid) cracks.add(i) | |
| 122 | } | |
| 123 | events.push({ type: 'exploded', pos: bomb.pos, tiles }) | |
5551eb37 2026-10-20 Pavel Safronov | 124 | } |
5f066960 2026-10-26 Pavel Safronov | 125 | // A spill touched by flame burns whole (and may set off more bombs: round again). |
| 126 | const lit = [...oil].filter((i) => burned.has(i)) | |
| 127 | if (!lit.length) break | |
| 128 | const queue = [...lit] | |
| 129 | for (const i of lit) oil.delete(i) | |
| 130 | for (let k = 0; k < queue.length; k++) { | |
| 131 | catchFire(queue[k]!) | |
| 132 | for (const j of neighbours(s, queue[k]!)) if (oil.delete(j)) queue.push(j) | |
| 133 | } | |
| 134 | if (!s.bombs.some((b) => b.timer <= 0)) break | |
5551eb37 2026-10-20 Pavel Safronov | 135 | } |
5f066960 2026-10-26 Pavel Safronov | 136 | s.oil = [...oil] |
| 137 | // Burned bushes are gone; those next to them smoulder. | |
| 138 | const burnedBushes = s.bushes.filter((i) => burned.has(i)) | |
| 139 | s.bushes = s.bushes.filter((i) => !burned.has(i)) | |
| 140 | s.smoldering = s.bushes.filter((i) => burnedBushes.some((b) => neighbours(s, b).includes(i))) | |
| 141 | if (spread.length) events.push({ type: 'fireSpread', tiles: spread }) | |
| 142 | ||
5551eb37 2026-10-20 Pavel Safronov | 143 | for (const i of hitBlocks) { |
| 144 | s.cells[i] = 'floor' | |
5f066960 2026-10-26 Pavel Safronov | 145 | events.push({ type: 'blockDestroyed', pos: at(i) }) |
| 146 | } | |
| 147 | for (const i of hitBarrels) { | |
| 148 | s.cells[i] = 'floor' | |
| 149 | for (const j of [i, ...neighbours(s, i)]) if (s.cells[j] === 'floor' && !s.oil.includes(j)) s.oil.push(j) | |
| 150 | s.bombs.push({ pos: at(i), timer: 1, fire: BARREL_FIRE, placedTurn: s.turn, barrel: true }) | |
| 151 | events.push({ type: 'barrelLit', pos: at(i) }) | |
| 152 | } | |
| 153 | for (const i of cracks) { | |
| 154 | if (breaks.has(i) || s.cracked.includes(i)) continue | |
| 155 | s.cracked.push(i) | |
| 156 | events.push({ type: 'pillarCracked', pos: at(i) }) | |
5551eb37 2026-10-20 Pavel Safronov | 157 | } |
5f066960 2026-10-26 Pavel Safronov | 158 | for (const i of breaks) breakPillar(s, i, events) |
5551eb37 2026-10-20 Pavel Safronov | 159 | return burned |
| 160 | } | |
| 161 | ||
5f066960 2026-10-26 Pavel Safronov | 162 | /** The 4 tiles next to tile `i` that are on the board. */ |
| 163 | function neighbours(s: GameState, i: number): number[] { | |
| 164 | const x = i % s.width | |
| 165 | return [x > 0 ? i - 1 : -1, x < s.width - 1 ? i + 1 : -1, i - s.width, i + s.width].filter((j) => j >= 0 && j < s.cells.length) | |
| 166 | } | |
| 167 | ||
| 168 | /** | |
| 169 | * A cracked pillar broken: its tile becomes floor, and the ceiling over the 8 tiles round it will | |
| 170 | * fall (never on the outer wall, the exit or a gem, so a level can always be finished). | |
| 171 | */ | |
| 172 | function breakPillar(s: GameState, i: number, events: GameEvent[]): void { | |
| 173 | const p = { x: i % s.width, y: Math.floor(i / s.width) } | |
| 174 | s.cells[i] = 'floor' | |
| 175 | s.cracked = s.cracked.filter((c) => c !== i) | |
| 176 | const cells: number[] = [] | |
| 177 | for (let dy = -1; dy <= 1; dy++) { | |
| 178 | for (let dx = -1; dx <= 1; dx++) { | |
| 179 | const q = { x: p.x + dx, y: p.y + dy } | |
| 180 | if ((!dx && !dy) || q.x <= 0 || q.y <= 0 || q.x >= s.width - 1 || q.y >= s.height - 1) continue | |
| 181 | if (eq(q, s.exit) || s.gems.some((g) => eq(g.pos, q)) || isSolid(cellAt(s, q))) continue | |
| 182 | cells.push(idx(s, q)) | |
| 183 | } | |
| 184 | } | |
| 185 | s.collapsing.push({ cells, turns: COLLAPSE_TURNS }) | |
| 186 | events.push({ type: 'pillarBroke', pos: p, tiles: cells.map((c) => ({ x: c % s.width, y: Math.floor(c / s.width) })), turns: COLLAPSE_TURNS }) | |
| 187 | } | |
| 188 | ||
| 189 | /** | |
| 190 | * Phase 3, before the blasts: falling ceilings count down, and those at 0 come down as rubble, | |
| 191 | * for good. What is under it is crushed: enemies (no drops), drops, bombs, bushes, oil, spikes. | |
| 192 | * Returns the tiles that fell this turn (the player under one is crushed: `step` decides). | |
| 193 | */ | |
| 194 | export function collapse(s: GameState, events: GameEvent[]): Set<number> { | |
| 195 | const fell = new Set<number>() | |
| 196 | for (const c of s.collapsing) if (--c.turns <= 0) for (const i of c.cells) fell.add(i) | |
| 197 | s.collapsing = s.collapsing.filter((c) => c.turns > 0) | |
| 198 | if (!fell.size) return fell | |
| 199 | for (const i of fell) s.cells[i] = 'rubble' | |
| 200 | const under = (p: Vec) => fell.has(idx(s, p)) | |
| 201 | s.enemies = s.enemies.filter((e) => !under(e.pos)) | |
| 202 | s.drops = s.drops.filter((d) => !under(d.pos)) | |
| 203 | s.bombs = s.bombs.filter((b) => !under(b.pos)) | |
| 204 | s.bushes = s.bushes.filter((i) => !fell.has(i)) | |
| 205 | s.smoldering = s.smoldering.filter((i) => !fell.has(i)) | |
| 206 | s.oil = s.oil.filter((i) => !fell.has(i)) | |
| 207 | s.spikes = s.spikes.filter((sp) => !fell.has(sp.cell)) | |
| 208 | s.cracked = s.cracked.filter((i) => !fell.has(i)) | |
| 209 | events.push({ type: 'ceilingFell', tiles: [...fell].map((i) => ({ x: i % s.width, y: Math.floor(i / s.width) })) }) | |
| 210 | return fell | |
| 211 | } | |
| 212 | ||
| 213 | /** | |
| 214 | * Whether the player is walled in for good by rubble: the exit, or an enemy (that must die for it | |
| 215 | * to open), is past what can be got through or burned away. Crates and barrels can be. | |
| 216 | */ | |
| 217 | export function walledIn(s: GameState): boolean { | |
| 218 | const seen = new Set([idx(s, s.player)]) | |
| 219 | const queue = [idx(s, s.player)] | |
| 220 | for (let k = 0; k < queue.length; k++) { | |
| 221 | for (const j of neighbours(s, queue[k]!)) { | |
| 222 | if (seen.has(j) || isSolid(s.cells[j]!)) continue | |
| 223 | seen.add(j) | |
| 224 | queue.push(j) | |
| 225 | } | |
| 226 | } | |
| 227 | return (inBounds(s, s.exit) && !seen.has(idx(s, s.exit))) || s.enemies.some((e) => !seen.has(idx(s, e.pos))) | |
| 228 | } | |
| 229 | ||
| 230 | /** Whether spikes are up on tile `i` at turn `turn` (they hurt then). */ | |
| 231 | export function spikesUp(s: GameState, i: number, turn = s.turn): boolean { | |
| 232 | return s.spikes.some((sp) => sp.cell === i && (turn + sp.phase) % 2 === 0) | |
| 233 | } | |
| 234 | ||
7f242d36 2026-10-25 Pavel Safronov | 235 | /** |
| 236 | * Proximity alert (user request, 2026-10-24): each of the player's proximity bombs with an enemy | |
d3e4ed26 2026-10-25 Pavel Safronov | 237 | * **next to it** (one of its 4 neighbouring tiles) goes off this turn. `enemyTiles`: where the |
| 238 | * enemies are (the danger overlay passes where they are going next). *Since 2026-10-25 (user | |
| 239 | * request):* it went off for an enemy anywhere in its reach, which the balance lab found turned the | |
| 240 | * defensive bomb (dropped between you and an enemy in a corridor) into a trap for its owner. | |
7f242d36 2026-10-25 Pavel Safronov | 241 | */ |
| 242 | export function proximityAlert(s: GameState, events: GameEvent[], enemyTiles: Vec[] = s.enemies.map((e) => e.pos)): void { | |
| 243 | for (const b of s.bombs) { | |
| 244 | if (!b.proximity || b.timer <= 0) continue | |
d3e4ed26 2026-10-25 Pavel Safronov | 245 | if (!enemyTiles.some((e) => Math.abs(e.x - b.pos.x) + Math.abs(e.y - b.pos.y) === 1)) continue |
7f242d36 2026-10-25 Pavel Safronov | 246 | b.timer = 0 |
| 247 | events.push({ type: 'proximityTriggered', pos: { ...b.pos } }) | |
| 248 | } | |
| 249 | } | |
| 250 | ||
5551eb37 2026-10-20 Pavel Safronov | 251 | /** Phase 3's countdown: every bomb not placed this turn loses 1 from its timer. */ |
| 252 | export function tickBombs(s: GameState, events: GameEvent[]): void { | |
| 253 | for (const b of s.bombs) { | |
| 254 | if (b.placedTurn === s.turn) continue | |
| 255 | b.timer-- | |
| 256 | events.push({ type: 'bombTicked', pos: b.pos, timer: b.timer }) | |
| 257 | } | |
| 258 | } |