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)