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        )