explodorama / files / src / sim / explosions.ts
explosions.ts
| 1 | import { NUKE_RANGE } from './actions' |
| 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' |
| 4 | |
| 5 | /** |
| 6 | * The tiles a bomb's flame reaches: its own tile, then up to `fire` tiles in each direction. |
| 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, |
| 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). |
| 12 | */ |
| 13 | export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'pierce' | 'nuke' | 'acid'>): Vec[] { |
| 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) |
| 20 | if (isSolid(cell)) break |
| 21 | tiles.push(p) |
| 22 | if ((cell === 'block' && !bomb.pierce) || cell === 'barrel' || bombAt(s, p)) break |
| 23 | } |
| 24 | } |
| 25 | if (bomb.nuke || bomb.acid) { |
| 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 }) |
| 29 | if (Math.abs(dx) + Math.abs(dy) > NUKE_RANGE || isSolid(cellAt(s, p))) continue |
| 30 | if (!tiles.some((t) => t.x === p.x && t.y === p.y)) tiles.push(p) |
| 31 | } |
| 32 | } |
| 33 | } |
| 34 | return tiles |
| 35 | } |
| 36 | |
| 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 | |
| 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[] { |
| 48 | const pillars: Vec[] = [] |
| 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 | } |
| 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) |
| 62 | if (isPillar(s, p) && !pillars.some((q) => eq(q, p))) pillars.push(p) |
| 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 | |
| 74 | /** |
| 75 | * Explode every bomb whose timer is at 0 or below, and every bomb their flame reaches, in a |
| 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. |
| 86 | */ |
| 87 | export function detonate(s: GameState, events: GameEvent[]): Set<number> { |
| 88 | const burned = new Set<number>() |
| 89 | const hitBlocks = new Set<number>() |
| 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) |
| 112 | let bomb: Bomb | undefined |
| 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 }) |
| 124 | } |
| 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 |
| 135 | } |
| 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 | |
| 143 | for (const i of hitBlocks) { |
| 144 | s.cells[i] = 'floor' |
| 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) }) |
| 157 | } |
| 158 | for (const i of breaks) breakPillar(s, i, events) |
| 159 | return burned |
| 160 | } |
| 161 | |
| 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 | |
| 235 | /** |
| 236 | * Proximity alert (user request, 2026-10-24): each of the player's proximity bombs with an enemy |
| 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. |
| 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 |
| 245 | if (!enemyTiles.some((e) => Math.abs(e.x - b.pos.x) + Math.abs(e.y - b.pos.y) === 1)) continue |
| 246 | b.timer = 0 |
| 247 | events.push({ type: 'proximityTriggered', pos: { ...b.pos } }) |
| 248 | } |
| 249 | } |
| 250 | |
| 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 | } |