nodes/fire.py¶
Part of Mr. Rescue.
1"""Fire grid: sparse cell map of flames with health and spread timers.
2
3Fires are not Node children; they're entries in a sparse dict keyed on
4``(cx, cy)``. The :class:`FireGrid` itself is a Node2D that draws all live
5flames in its ``on_draw`` (camera-space, since the grid is added under the
6gameplay scene).
7
8Design notes:
9- Each Fire has ``health`` ∈ [0, max_health]. Below max it regenerates.
10- Above max it counts down to a spread tick: when the timer hits zero, the
11 fire picks a random adjacent cell and seeds a new flame (if burnable).
12- ``min/max spread wait`` shrinks with section number → late sections are
13 significantly harder.
14- Damage to player & civilians is computed via :meth:`heat_at` (squared
15 inverse-distance falloff). Heat units are tuned to upstream's 0..1 scale.
16"""
17
18from __future__ import annotations
19
20import random
21
22from simvx.core import Node2D
23
24from .colours import FIRE_ORANGE, FIRE_RED, FIRE_YELLOW
25from .tile_grid import TILE_SIZE, TileGrid
26
27MAX_HEALTH = 0.4
28REGEN_RATE = 0.05
29FIRE_DIST_SQ = 1600.0 # upstream's FIRE_DIST^2 (1600 squared px units)
30
31
32class _Fire:
33 __slots__ = (
34 "cx",
35 "cy",
36 "x",
37 "y",
38 "health",
39 "frame",
40 "next_spread",
41 "min_wait",
42 "max_wait",
43 "alive",
44 "ground",
45 "ceiling",
46 )
47
48 def __init__(
49 self, cx: int, cy: int, *, section: int, grid: TileGrid, rng: random.Random, health: float | None = None
50 ):
51 self.cx = cx
52 self.cy = cy
53 self.x = cx * TILE_SIZE
54 self.y = cy * TILE_SIZE
55 self.health = MAX_HEALTH / 4 if health is None else health
56 self.frame = rng.uniform(0, 5)
57 self.alive = True
58 # Section-driven spread wait.
59 self.min_wait = max(2, round(8 - section * (6 / 26)))
60 self.max_wait = max(self.min_wait + 2, round(14 - section * (10 / 26)))
61 self.next_spread = rng.uniform(self.min_wait, self.max_wait)
62 # Floor / ceiling adjacency for visual rim.
63 self.ground = grid.is_solid(cx, cy + 1)
64 self.ceiling = grid.is_solid(cx, cy - 1)
65
66
67class FireGrid(Node2D):
68 # ``on_draw`` animates each flame's ``frame`` wobble from the non-Property
69 # ``_fires`` dict every tick, so it genuinely produces fresh geometry every
70 # frame: declare it dynamic so the item pipeline re-collects it each frame
71 # (correct on both desktop and web, independent of camera scroll).
72 dynamic = True
73
74 def __init__(self, *, grid: TileGrid, section: int = 1, seed: int | None = None, **kwargs):
75 super().__init__(**kwargs)
76 self.grid = grid
77 self.section = section
78 self._rng = random.Random(seed)
79 self._fires: dict[tuple[int, int], _Fire] = {}
80 # Stats: read by HUD / score.
81 self.fires_extinguished = 0
82
83 # ---------------------------------------------------------------- spawn
84
85 def seed(self, cells: list[tuple[int, int]]):
86 for cx, cy in cells:
87 self.spawn(cx, cy, health=MAX_HEALTH)
88
89 def spawn(self, cx: int, cy: int, *, health: float | None = None) -> bool:
90 if (cx, cy) in self._fires:
91 # Already burning: top up health if seeded with full.
92 existing = self._fires[(cx, cy)]
93 if health is not None:
94 existing.health = max(existing.health, health)
95 return False
96 if not self.grid.is_burnable(cx, cy):
97 return False
98 f = _Fire(cx, cy, section=self.section, grid=self.grid, rng=self._rng, health=health)
99 self._fires[(cx, cy)] = f
100 return True
101
102 def remove(self, cx: int, cy: int):
103 if (cx, cy) in self._fires:
104 self._fires[(cx, cy)].alive = False
105 del self._fires[(cx, cy)]
106
107 # ---------------------------------------------------------------- queries
108
109 def has_fire(self, cx: int, cy: int) -> bool:
110 return (cx, cy) in self._fires
111
112 def fire_count(self) -> int:
113 return len(self._fires)
114
115 def heat_at(self, x: float, y: float) -> float:
116 """Sum of nearby fires' heat contributions, scaled 0..1."""
117 cx, cy = self.grid.cell_for(x, y)
118 total = 0.0
119 for ix in range(cx - 2, cx + 3):
120 for iy in range(cy - 2, cy + 3):
121 f = self._fires.get((ix, iy))
122 if f is None:
123 continue
124 fx, fy = ix * TILE_SIZE + TILE_SIZE / 2, iy * TILE_SIZE + TILE_SIZE / 2
125 dx, dy = x - fx, y - fy
126 d2 = dx * dx + dy * dy
127 if d2 <= FIRE_DIST_SQ:
128 damage = f.health / MAX_HEALTH
129 falloff = (1 - d2 / FIRE_DIST_SQ) ** 2
130 total += falloff * damage * 0.5
131 return min(1.0, total)
132
133 # ---------------------------------------------------------------- damage
134
135 def shoot(self, cx: int, cy: int, dt: float) -> bool:
136 """Apply 1-tick of water damage. Returns True if extinguished this frame."""
137 f = self._fires.get((cx, cy))
138 if f is None:
139 return False
140 f.health -= dt
141 if f.health < 0:
142 self.remove(cx, cy)
143 self.fires_extinguished += 1
144 return True
145 return False
146
147 # ---------------------------------------------------------------- update
148
149 def on_update(self, dt: float):
150 # Iterate over a snapshot so spawn during update doesn't disturb us.
151 spawned_this_frame: list[tuple[int, int]] = []
152 for f in list(self._fires.values()):
153 if not f.alive:
154 continue
155 if f.health < MAX_HEALTH:
156 f.health = min(MAX_HEALTH, f.health + dt * REGEN_RATE)
157 else:
158 f.next_spread -= dt
159 if f.next_spread <= 0:
160 target = self._pick_spread_target(f)
161 if target is not None:
162 spawned_this_frame.append(target)
163 f.next_spread = self._rng.uniform(f.min_wait, f.max_wait)
164 f.frame += 12 * dt
165 for cx, cy in spawned_this_frame:
166 self.spawn(cx, cy)
167
168 def _pick_spread_target(self, f: _Fire) -> tuple[int, int] | None:
169 for _ in range(6):
170 if self._rng.random() < 0.5:
171 # Vertical
172 cx, cy = f.cx, f.cy + (-1 if self._rng.random() < 0.5 else 1)
173 # 1-in-5 burn through solid floor/ceiling
174 if not self.grid.is_burnable(cx, cy) and self._rng.random() < 0.2:
175 cy = cy + (-1 if cy < f.cy else 1)
176 else:
177 cx, cy = f.cx + (-1 if self._rng.random() < 0.5 else 1), f.cy
178 if (cx, cy) == (f.cx, f.cy):
179 continue
180 if self.grid.is_burnable(cx, cy) and (cx, cy) not in self._fires:
181 return (cx, cy)
182 return None
183
184 # ---------------------------------------------------------------- draw
185
186 def on_draw(self, renderer):
187 for f in self._fires.values():
188 frame_idx = int(f.frame) % 5
189 cx_px = f.x + TILE_SIZE / 2
190 cy_px = f.y + TILE_SIZE / 2
191 # By design the flame is immediate-mode geometry, not a sprite: a
192 # stack of three coloured rects whose width tracks the fire's
193 # health and whose mid band wobbles per animation frame.
194 health_ratio = f.health / MAX_HEALTH
195 self._draw_flame(renderer, cx_px, cy_px, health_ratio, frame_idx)
196
197 def _draw_flame(self, renderer, cx: float, cy: float, ratio: float, frame: int):
198 # Wobble: alternate width per frame.
199 scale = 0.7 + ratio * 0.5
200 # Bottom band: red
201 renderer.draw_rect(
202 (cx - 7 * scale, cy + 1),
203 (14 * scale, 6),
204 colour=FIRE_RED,
205 filled=True,
206 )
207 # Mid band: orange (slightly narrower, wobble offset by frame)
208 wobble = (frame % 3 - 1) * 0.5
209 renderer.draw_rect(
210 (cx - 5 * scale + wobble, cy - 4),
211 (10 * scale, 6),
212 colour=FIRE_ORANGE,
213 filled=True,
214 )
215 # Top tip: yellow
216 renderer.draw_rect(
217 (cx - 3 * scale - wobble, cy - 8),
218 (6 * scale, 5),
219 colour=FIRE_YELLOW,
220 filled=True,
221 )