nodes/tile_grid.pyΒΆ
Part of Mr. Rescue.
1"""Tile grid + collision for the building.
2
3Internal coordinate system uses 16-px tiles. Players/enemies/civilians live
4in pixel space; collisions snap to the integer cell containing the corner.
5
6Tile IDs (matching upstream's intent, simplified):
7 0: empty interior (walkable, civilian-walk allowed)
8 1: solid wall (blocks all movement, blocks fire spread)
9 2: floor (solid from above, walk on top)
10 3: ladder (climbable; semi-solid, pass-through if not "running on top")
11 4: door (solid; visual only, used as backdrop)
12 5: sky (visual; out-of-bounds, player can climb out the top here)
13
14The grid is rendered as Sprite2D children of a top-level Node2D so the
15camera transform applies. Solid lookups stay on a flat numpy uint8 array
16for speed.
17"""
18
19from __future__ import annotations
20
21import numpy as np
22
23from simvx.core import Node2D, Sprite2D, Vec2
24
25from . import textures
26
27TILE_SIZE = 16
28
29# IDs
30T_EMPTY = 0
31T_WALL = 1
32T_FLOOR = 2
33T_LADDER = 3
34T_DOOR = 4
35T_SKY = 5
36
37# Decorative back-wall layer (non-solid, drawn behind gameplay sprites). These
38# replace the empty interior so the building no longer reads as a void. The
39# three wallpaper variants give each storey its own tint.
40T_PAPER0 = 6 # bottom storey wallpaper
41T_PAPER1 = 7 # middle storey wallpaper
42T_PAPER2 = 8 # top storey wallpaper
43T_WINDOW = 9 # lit window onto the night skyline
44T_WAINSCOT = 10 # dark baseboard skirting
45T_TRIM = 11 # bright ceiling/floor trim line
46T_SHELF = 12 # furniture: shelf
47T_CRATE = 13 # furniture: crate
48T_PLANT = 14 # furniture: potted plant
49T_PICTURE = 15 # wall: framed picture
50T_PIPE = 16 # wall: vertical pipe run
51
52PAPER_IDS = (T_PAPER0, T_PAPER1, T_PAPER2)
53
54SOLID_IDS = {T_WALL, T_FLOOR} # T_DOOR + all decoration are walk-through
55LADDER_IDS = {T_LADDER}
56# Wallpaper replaces interior air, so it must burn exactly like the air it
57# replaced or fire would stop spreading through rooms. The skirting board covers
58# the row everything stands in, so it burns too: without it flames could never
59# take hold at floor level, which is where they start and where the hose reaches.
60# Props burn (fire consuming furniture); window/trim/pipe do not.
61BURNABLE_IDS = {
62 T_EMPTY,
63 T_LADDER,
64 T_DOOR,
65 T_WAINSCOT,
66 T_PAPER0,
67 T_PAPER1,
68 T_PAPER2,
69 T_SHELF,
70 T_CRATE,
71 T_PLANT,
72 T_PICTURE,
73}
74
75
76class TileGrid(Node2D):
77 """Tile-based building. ``data`` is a (H, W) uint8 array."""
78
79 def __init__(self, data: np.ndarray, **kwargs):
80 super().__init__(**kwargs)
81 self.data = np.asarray(data, dtype=np.uint8)
82 self.h, self.w = self.data.shape
83 self._build_sprites()
84
85 # -------------------------------------------------------------- queries
86
87 def in_bounds(self, cx: int, cy: int) -> bool:
88 return 0 <= cx < self.w and 0 <= cy < self.h
89
90 def tile_at(self, cx: int, cy: int) -> int:
91 if cy < 0:
92 # Above the roof is open sky, not stone: this is where a carried
93 # civilian is taken, so it must not read as a solid ceiling.
94 return T_SKY
95 if not self.in_bounds(cx, cy):
96 return T_WALL # out of bounds = solid (player can't escape sideways)
97 return int(self.data[cy, cx])
98
99 def is_solid(self, cx: int, cy: int) -> bool:
100 return self.tile_at(cx, cy) in SOLID_IDS
101
102 def is_ladder(self, cx: int, cy: int) -> bool:
103 return self.tile_at(cx, cy) in LADDER_IDS
104
105 def is_burnable(self, cx: int, cy: int) -> bool:
106 if not self.in_bounds(cx, cy):
107 return False
108 t = self.tile_at(cx, cy)
109 return t in BURNABLE_IDS
110
111 def collides_box(self, x: float, y: float, w: float, h: float) -> bool:
112 """AABB vs solid tiles. ``(x, y)`` is the top-left of the box."""
113 x0 = int(x // TILE_SIZE)
114 x1 = int((x + w - 1) // TILE_SIZE)
115 y0 = int(y // TILE_SIZE)
116 y1 = int((y + h - 1) // TILE_SIZE)
117 for cy in range(y0, y1 + 1):
118 for cx in range(x0, x1 + 1):
119 if self.is_solid(cx, cy):
120 return True
121 return False
122
123 def cell_for(self, x: float, y: float) -> tuple[int, int]:
124 return int(x // TILE_SIZE), int(y // TILE_SIZE)
125
126 # -------------------------------------------------------------- mutate
127
128 def set_tile(self, cx: int, cy: int, tile: int) -> None:
129 if not self.in_bounds(cx, cy):
130 return
131 self.data[cy, cx] = tile
132 self._refresh_sprite(cx, cy)
133
134 # -------------------------------------------------------------- visuals
135
136 def _build_sprites(self):
137 # Single Node2D parent per layer so we can z-order easily.
138 for cy in range(self.h):
139 for cx in range(self.w):
140 self._spawn_sprite(cx, cy)
141
142 def _spawn_sprite(self, cx: int, cy: int):
143 tile = int(self.data[cy, cx])
144 tex = self._tex_for(tile)
145 if tex is None:
146 return
147 sprite = Sprite2D(
148 texture=tex,
149 position=Vec2(cx * TILE_SIZE + TILE_SIZE / 2, cy * TILE_SIZE + TILE_SIZE / 2),
150 width=TILE_SIZE,
151 height=TILE_SIZE,
152 filter="nearest",
153 )
154 sprite._cell = (cx, cy) # type: ignore[attr-defined]
155 self.add_child(sprite)
156
157 def _refresh_sprite(self, cx: int, cy: int):
158 # Dumb: drop all sprites for this cell, respawn.
159 target = (cx, cy)
160 for child in list(self.children):
161 if getattr(child, "_cell", None) == target:
162 child.destroy()
163 self._spawn_sprite(cx, cy)
164
165 _TEX_NAMES = {
166 T_WALL: "wall",
167 T_FLOOR: "floor",
168 T_LADDER: "ladder",
169 T_DOOR: "door",
170 T_SKY: "sky",
171 T_PAPER0: "wallpaper_0",
172 T_PAPER1: "wallpaper_1",
173 T_PAPER2: "wallpaper_2",
174 T_WINDOW: "window",
175 T_WAINSCOT: "wainscot",
176 T_TRIM: "trim",
177 T_SHELF: "shelf",
178 T_CRATE: "crate",
179 T_PLANT: "plant",
180 T_PICTURE: "picture",
181 T_PIPE: "pipe",
182 }
183
184 def _tex_for(self, tile: int):
185 name = self._TEX_NAMES.get(tile)
186 if name is None:
187 return None # T_EMPTY = no sprite, transparent
188 return textures.get(name)