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)