nodes/building.py

Part of Mr. Rescue.

  1"""Procedural multi-floor building generator.
  2
  3Three floors, each 16 tiles tall, 41 tiles wide (matches upstream MAPW=41,
  4floor-height = 16). Floors are stitched vertically: bottom floor at rows
  532..47, middle 16..31, top 0..15. Ladders connect them at randomised x.
  6
  7Each floor uses a template skeleton: outer walls, floor strip at the top
  8and bottom of the band, plus 2 internal "rooms" with door gaps. Random
  9spawn points are produced for the player, civilians, enemies, and fires.
 10"""
 11
 12from __future__ import annotations
 13
 14import random
 15from dataclasses import dataclass
 16
 17import numpy as np
 18
 19from .tile_grid import (
 20    PAPER_IDS,
 21    T_CRATE,
 22    T_DOOR,
 23    T_EMPTY,
 24    T_FLOOR,
 25    T_LADDER,
 26    T_PICTURE,
 27    T_PIPE,
 28    T_PLANT,
 29    T_SHELF,
 30    T_WAINSCOT,
 31    T_WALL,
 32    T_WINDOW,
 33    TILE_SIZE,
 34    TileGrid,
 35)
 36
 37WIDTH_TILES = 41
 38FLOOR_HEIGHT = 16
 39NUM_FLOORS = 3
 40HEIGHT_TILES = FLOOR_HEIGHT * NUM_FLOORS  # 48
 41
 42# Interior height inside each floor (rows between top and bottom solid bands).
 43INTERIOR_TOP = 1  # 1 row of ceiling at the top of each band
 44INTERIOR_BOTTOM = 14  # 14th row is the floor surface
 45# So inside band: rows 1..14 free, row 15 is floor surface (T_FLOOR).
 46
 47
 48@dataclass
 49class SpawnPlan:
 50    """Output of :func:`generate_layout`."""
 51
 52    data: np.ndarray
 53    player_start: tuple[float, float]  # pixel coords (centre of feet)
 54    civilian_spawns: list[tuple[float, float]]
 55    enemy_spawns: list[tuple[float, float]]
 56    fire_seeds: list[tuple[int, int]]  # cell coords
 57
 58
 59# --------------------------------------------------------------------- helpers
 60
 61
 62def _hline(data: np.ndarray, row: int, x0: int, x1: int, tile: int):
 63    data[row, x0 : x1 + 1] = tile
 64
 65
 66def _vline(data: np.ndarray, col: int, y0: int, y1: int, tile: int):
 67    data[y0 : y1 + 1, col] = tile
 68
 69
 70def _gen_floor(
 71    data: np.ndarray,
 72    band_top: int,
 73    rng: random.Random,
 74    *,
 75    is_topmost: bool,
 76    ladders_above: list[int],
 77    ladders_below: list[int],
 78):
 79    """Carve a single floor band into ``data``. ``band_top`` = first row.
 80
 81    Lays:
 82      - solid floor at row band_top + 14 (this is what the player walks on)
 83      - top ceiling at band_top (only if not topmost; the topmost band's
 84        ceiling is sky for "exit upward")
 85      - 2 internal walls with door gaps to make rooms
 86      - ladders at the supplied x coords (climbing into / out of this band)
 87    """
 88    band_bot_floor = band_top + INTERIOR_BOTTOM + 1  # 15
 89    band_top_ceil = band_top  # 0 of band
 90
 91    # Floor surface
 92    _hline(data, band_bot_floor, 0, WIDTH_TILES - 1, T_FLOOR)
 93
 94    # Ceiling: solid except: skip cells where ladders pass through (so the
 95    # player can climb up). Also skip the topmost band entirely (open sky).
 96    if not is_topmost:
 97        _hline(data, band_top_ceil, 0, WIDTH_TILES - 1, T_FLOOR)
 98        for lx in ladders_above:
 99            data[band_top_ceil, lx] = T_LADDER
100
101    # Outer walls (left + right)
102    _vline(data, 0, band_top_ceil, band_bot_floor, T_WALL)
103    _vline(data, WIDTH_TILES - 1, band_top_ceil, band_bot_floor, T_WALL)
104
105    # Internal walls (2 walls, dividing band into 3 rooms with doors).
106    # Wall x coords picked from interior bands, minus the columns this band's
107    # ladders run through: ladder cells are only carved into empty space, so a
108    # wall landing on one would seal the storey off with no way up or down.
109    ladder_cols = {*ladders_above, *ladders_below}
110    wall_candidates = [x for x in range(8, WIDTH_TILES - 8, 4) if x not in ladder_cols]
111    wall_xs = sorted(rng.sample(wall_candidates, 2))
112    for wx in wall_xs:
113        _vline(data, wx, band_top_ceil + 1, band_bot_floor - 1, T_WALL)
114        # Door gap: 2 tiles tall above floor.
115        data[band_bot_floor - 1, wx] = T_DOOR
116        data[band_bot_floor - 2, wx] = T_DOOR
117
118    # Ladders into this band's floor (so player can drop through).
119    for lx in ladders_below:
120        # Cut the floor + 1 cell above to make the ladder reach upward.
121        data[band_bot_floor, lx] = T_LADDER
122        # Continue ladder upward by 14 cells (entire interior).
123        for ly in range(band_bot_floor - 1, band_top_ceil, -1):
124            if data[ly, lx] == T_EMPTY:
125                data[ly, lx] = T_LADDER
126
127    # Same for ladders coming up from above (climbed-into ceiling).
128    for lx in ladders_above:
129        for ly in range(band_top_ceil + 1, band_bot_floor):
130            if data[ly, lx] == T_EMPTY:
131                data[ly, lx] = T_LADDER
132
133
134def _decorate(data: np.ndarray, rng: random.Random, floor_band_tops: list[int], occupied: set[tuple[int, int]]):
135    """Paint the decorative back-wall layer over the bare interior.
136
137    Runs AFTER spawn selection (which keys off structural T_EMPTY), so it never
138    perturbs where civilians / fires / items land. Only ever overwrites
139    ``T_EMPTY`` (and its own wallpaper), leaving walls, floors, ladders and
140    doors untouched. All decoration is non-solid, so collision/platforming and
141    fire spread are unchanged (wallpaper stays burnable like the air it
142    replaces).
143    """
144    ground_props = (T_SHELF, T_CRATE, T_PLANT)
145    for s, band_top in enumerate(floor_band_tops):
146        paper = PAPER_IDS[s % len(PAPER_IDS)]
147        band_bot_floor = band_top + INTERIOR_BOTTOM + 1  # floor surface row
148        top_row = band_top + 1
149        bot_row = band_bot_floor - 1  # interior row on the floor
150
151        # 1) Back-fill every empty interior cell with this storey's wallpaper.
152        for cy in range(top_row, band_bot_floor):
153            for cx in range(WIDTH_TILES):
154                if data[cy, cx] == T_EMPTY:
155                    data[cy, cx] = paper
156
157        # 2) Skirting board along the floor.
158        for cx in range(1, WIDTH_TILES - 1):
159            if data[bot_row, cx] == paper:
160                data[bot_row, cx] = T_WAINSCOT
161
162        # 3) Windows near the ceiling, looking onto the night skyline.
163        offset = rng.randrange(0, 6)
164        for cx in range(2 + offset, WIDTH_TILES - 2, 6):
165            for cy in range(band_top + 2, band_top + 5):
166                if data[cy, cx] == paper:
167                    data[cy, cx] = T_WINDOW
168
169        # 4) Back-wall props: framed pictures + the odd vertical pipe run.
170        for cx in range(3, WIDTH_TILES - 3):
171            if rng.random() < 0.05 and data[band_top + 6, cx] == paper and (cx, band_top + 6) not in occupied:
172                data[band_top + 6, cx] = T_PICTURE
173        if rng.random() < 0.7:
174            px = rng.randrange(4, WIDTH_TILES - 4)
175            for cy in range(top_row, bot_row):
176                if data[cy, px] == paper:
177                    data[cy, px] = T_PIPE
178
179        # 5) Ground furniture standing on the skirting (replaces it at a few cols).
180        cols = list(range(2, WIDTH_TILES - 2))
181        rng.shuffle(cols)
182        placed = 0
183        want = rng.randint(4, 7)
184        for cx in cols:
185            if placed >= want:
186                break
187            if data[bot_row, cx] == T_WAINSCOT and data[bot_row - 1, cx] == paper and (cx, bot_row) not in occupied:
188                data[bot_row, cx] = rng.choice(ground_props)
189                placed += 1
190
191
192def generate_layout(*, section: int = 1, seed: int | None = None) -> SpawnPlan:
193    """Generate a SpawnPlan for the given section.
194
195    ``section`` (1+) scales fire seeds, enemy count, and ladder placement
196    randomness. The result is fully deterministic given a seed.
197    """
198    rng = random.Random(seed)
199
200    data = np.full((HEIGHT_TILES, WIDTH_TILES), T_EMPTY, dtype=np.uint8)
201
202    # Sky band above the building: top row = open exit zone.
203    # We don't actually render rows below 0; instead, the topmost band has
204    # an open ceiling so the player can climb out the top.
205
206    # Pick ladder column for each floor boundary.
207    # Two boundaries (between floor 0/1 and 1/2). Each boundary needs a single
208    # ladder column shared by the floor below (its ceiling) and floor above
209    # (its floor cut).
210    ladder_lower = rng.randrange(8, WIDTH_TILES - 8)
211    ladder_upper = rng.randrange(8, WIDTH_TILES - 8)
212    while abs(ladder_upper - ladder_lower) < 6:
213        ladder_upper = rng.randrange(8, WIDTH_TILES - 8)
214
215    # Floor band tops (in tile rows): 0 (top), 16, 32 (bottom).
216    floor_band_tops = [0, FLOOR_HEIGHT, FLOOR_HEIGHT * 2]
217
218    # Order of generation: bottom floor (band_top=32) first.
219    # Bottom band: ladder_above goes into ceiling at band_top=32 → ladder_lower.
220    _gen_floor(
221        data,
222        band_top=floor_band_tops[2],
223        rng=rng,
224        is_topmost=False,
225        ladders_above=[ladder_lower],
226        ladders_below=[],
227    )
228    # Middle band: ladder_above = ladder_upper, ladder_below = ladder_lower.
229    _gen_floor(
230        data,
231        band_top=floor_band_tops[1],
232        rng=rng,
233        is_topmost=False,
234        ladders_above=[ladder_upper],
235        ladders_below=[ladder_lower],
236    )
237    # Top band: ladder_below = ladder_upper. No ceiling (open sky) so player
238    # can exit out the top.
239    _gen_floor(
240        data,
241        band_top=floor_band_tops[0],
242        rng=rng,
243        is_topmost=True,
244        ladders_above=[],
245        ladders_below=[ladder_upper],
246    )
247
248    # Solid floor surface of each band: everything that walks does so with its
249    # feet on the top edge of these rows, and spawns go in the row directly above.
250    floor_rows = [bt + INTERIOR_BOTTOM + 1 for bt in floor_band_tops]
251
252    # Spawns ---------------------------------------------------
253    # Player: bottom floor, near left wall, on top of floor surface.
254    pstart_cx = 4
255    pstart_cy = floor_rows[2] - 1  # one row above floor surface
256    player_start = (
257        pstart_cx * TILE_SIZE + TILE_SIZE / 2,
258        pstart_cy * TILE_SIZE + TILE_SIZE,
259    )  # bottom-of-feet at floor top
260
261    civilian_spawns: list[tuple[float, float]] = []
262    enemy_spawns: list[tuple[float, float]] = []
263    fire_seeds: list[tuple[int, int]] = []
264
265    occupied: set[tuple[int, int]] = set()
266
267    # 2-3 civilians per floor band so the building feels inhabited.
268    for fr in floor_rows:
269        want = rng.randint(2, 3)
270        placed = 0
271        for _ in range(40):
272            if placed >= want:
273                break
274            cx = rng.randrange(3, WIDTH_TILES - 3)
275            cy = fr - 1
276            if data[cy, cx] != T_EMPTY:
277                continue
278            if (cx, cy) in occupied:
279                continue
280            occupied.add((cx, cy))
281            civilian_spawns.append((cx * TILE_SIZE + TILE_SIZE / 2, cy * TILE_SIZE + TILE_SIZE))
282            placed += 1
283
284    # 1-2 enemies (scaled with section).
285    enemy_count = 1 + min(2, section // 3)
286    for _ in range(enemy_count):
287        for _try in range(20):
288            band = rng.randrange(NUM_FLOORS)
289            fr = floor_rows[band]
290            cx = rng.randrange(5, WIDTH_TILES - 5)
291            cy = fr - 1
292            if data[cy, cx] != T_EMPTY or (cx, cy) in occupied:
293                continue
294            occupied.add((cx, cy))
295            enemy_spawns.append((cx * TILE_SIZE + TILE_SIZE / 2, cy * TILE_SIZE + TILE_SIZE))
296            break
297
298    # Fires: 3-5 seeds, scaled with section. Each seed must be on a burnable
299    # cell adjacent to a floor surface (so flames have something to climb).
300    fire_count = min(10, 5 + section)
301    for _ in range(fire_count):
302        for _try in range(30):
303            band = rng.randrange(NUM_FLOORS)
304            fr = floor_rows[band]
305            cx = rng.randrange(2, WIDTH_TILES - 2)
306            cy = fr - 1
307            if data[cy, cx] != T_EMPTY or (cx, cy) in occupied:
308                continue
309            occupied.add((cx, cy))
310            fire_seeds.append((cx, cy))
311            break
312
313    # Decorate the interior LAST, once all spawns are chosen from structural
314    # emptiness, so the back-wall layer can't displace gameplay placement.
315    _decorate(data, rng, floor_band_tops, occupied)
316
317    return SpawnPlan(
318        data=data,
319        player_start=player_start,
320        civilian_spawns=civilian_spawns,
321        enemy_spawns=enemy_spawns,
322        fire_seeds=fire_seeds,
323    )
324
325
326def make_tile_grid(plan: SpawnPlan) -> TileGrid:
327    """Convenience wrapper to instantiate the renderable TileGrid from a plan."""
328    return TileGrid(plan.data)