fuzzyworld

explodorama / files / src / sim / explosions.ts

explosions.ts

Blame: the commit that last changed each line · file · history

52d68b59 2026-10-24 Pavel Safronov1import { NUKE_RANGE } from './actions'
5f066960 2026-10-26 Pavel Safronov2import { add, bombAt, cellAt, eq, idx, inBounds } from './state'
3import { DIRS, DIR_LIST, isSolid, type Bomb, type GameEvent, type GameState, type Vec } from './types'
5551eb37 2026-10-20 Pavel Safronov4
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 Safronov7 * 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 Safronov10 * 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 Safronov12 */
e3d8dc49 2026-10-27 Pavel Safronov13export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'pierce' | 'nuke' | 'acid'>): Vec[] {
5551eb37 2026-10-20 Pavel Safronov14 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 Safronov20 if (isSolid(cell)) break
5551eb37 2026-10-20 Pavel Safronov21 tiles.push(p)
5f066960 2026-10-26 Pavel Safronov22 if ((cell === 'block' && !bomb.pierce) || cell === 'barrel' || bombAt(s, p)) break
52d68b59 2026-10-24 Pavel Safronov23 }
24 }
e3d8dc49 2026-10-27 Pavel Safronov25 if (bomb.nuke || bomb.acid) {
52d68b59 2026-10-24 Pavel Safronov26 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 Safronov29 if (Math.abs(dx) + Math.abs(dy) > NUKE_RANGE || isSolid(cellAt(s, p))) continue
52d68b59 2026-10-24 Pavel Safronov30 if (!tiles.some((t) => t.x === p.x && t.y === p.y)) tiles.push(p)
31 }
5551eb37 2026-10-20 Pavel Safronov32 }
33 }
34 return tiles
35}
36
5f066960 2026-10-26 Pavel Safronov37/** A pillar: a wall inside the board, not its outer wall. */
38export 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 Safronov42/**
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 */
47export function pillarsReached(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire' | 'pierce' | 'acid'>): Vec[] {
5f066960 2026-10-26 Pavel Safronov48 const pillars: Vec[] = []
e3d8dc49 2026-10-27 Pavel Safronov49 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 Safronov57 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 Safronov62 if (isPillar(s, p) && !pillars.some((q) => eq(q, p))) pillars.push(p)
5f066960 2026-10-26 Pavel Safronov63 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). */
70export const COLLAPSE_TURNS = 4
71/** An oil barrel's blast, the turn after flame reaches it. */
72export const BARREL_FIRE = 2
73
5551eb37 2026-10-20 Pavel Safronov74/**
75 * Explode every bomb whose timer is at 0 or below, and every bomb their flame reaches, in a
5f066960 2026-10-26 Pavel Safronov76 * 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 Safronov86 */
87export 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 Safronov90 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 Safronov112 let bomb: Bomb | undefined
5f066960 2026-10-26 Pavel Safronov113 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 Safronov124 }
5f066960 2026-10-26 Pavel Safronov125 // 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 Safronov135 }
5f066960 2026-10-26 Pavel Safronov136 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 Safronov143 for (const i of hitBlocks) {
144 s.cells[i] = 'floor'
5f066960 2026-10-26 Pavel Safronov145 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 Safronov157 }
5f066960 2026-10-26 Pavel Safronov158 for (const i of breaks) breakPillar(s, i, events)
5551eb37 2026-10-20 Pavel Safronov159 return burned
160}
161
5f066960 2026-10-26 Pavel Safronov162/** The 4 tiles next to tile `i` that are on the board. */
163function 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 */
172function 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 */
194export 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 */
217export 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). */
231export 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 Safronov235/**
236 * Proximity alert (user request, 2026-10-24): each of the player's proximity bombs with an enemy
d3e4ed26 2026-10-25 Pavel Safronov237 * **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 Safronov241 */
242export 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 Safronov245 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 Safronov246 b.timer = 0
247 events.push({ type: 'proximityTriggered', pos: { ...b.pos } })
248 }
249}
250
5551eb37 2026-10-20 Pavel Safronov251/** Phase 3's countdown: every bomb not placed this turn loses 1 from its timer. */
252export 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}