scripts/dungeon_generator.py¶

Part of Dungeon Explorer.

  1"""BSP-based dungeon generator: rooms, corridors, floor/wall TileMap + collision.
  2
  3Generates a dungeon level as a TileMap node with:
  4- Layer 0: floor tiles
  5- Layer 1: wall tiles (with CollisionShape2D bodies for wall collision)
  6
  7The generator produces a grid array where each cell is FLOOR, WALL, ENTRANCE, or EXIT.
  8This grid is then stamped onto a TileMap and also exported as an NavGrid2D for pathfinding.
  9"""
 10
 11import math
 12import random
 13from dataclasses import dataclass, field
 14
 15import numpy as np
 16from collision_layers import LAYER_WORLD, MASK_ACTORS
 17
 18from simvx.core import (
 19    BodyMode,
 20    NavGrid2D,
 21    Node2D,
 22    PhysicsBody2D,
 23    Property,
 24    RectangleShape2D,
 25    TileData,
 26    TileMap,
 27    TileSet,
 28    Vec2,
 29)
 30
 31# Cell types
 32EMPTY = -1
 33FLOOR = 0
 34WALL = 1
 35ENTRANCE = 2
 36EXIT = 3
 37CORRIDOR = 4
 38SECRET_WALL = 5  # Hidden entrance to a secret room
 39
 40TILE_SIZE = 32
 41
 42
 43@dataclass
 44class Room:
 45    """A rectangular room in the dungeon."""
 46
 47    x: int
 48    y: int
 49    w: int
 50    h: int
 51
 52    @property
 53    def cx(self) -> int:
 54        return self.x + self.w // 2
 55
 56    @property
 57    def cy(self) -> int:
 58        return self.y + self.h // 2
 59
 60    @property
 61    def centre(self) -> tuple[int, int]:
 62        return (self.cx, self.cy)
 63
 64    def intersects(self, other: Room, padding: int = 1) -> bool:
 65        return not (
 66            self.x + self.w + padding <= other.x
 67            or other.x + other.w + padding <= self.x
 68            or self.y + self.h + padding <= other.y
 69            or other.y + other.h + padding <= self.y
 70        )
 71
 72
 73@dataclass
 74class DungeonData:
 75    """Output of dungeon generation: grid, rooms, and metadata."""
 76
 77    width: int
 78    height: int
 79    grid: list[list[int]]  # [y][x]
 80    rooms: list[Room]
 81    entrance: tuple[int, int]  # grid coords
 82    exit: tuple[int, int]  # grid coords
 83    special_objects: list[dict] = field(default_factory=list)
 84
 85    def cell(self, x: int, y: int) -> int:
 86        if 0 <= x < self.width and 0 <= y < self.height:
 87            return self.grid[y][x]
 88        return WALL
 89
 90    def is_walkable(self, x: int, y: int) -> bool:
 91        c = self.cell(x, y)
 92        return c in (FLOOR, ENTRANCE, EXIT, CORRIDOR)
 93
 94    def reveal_secret_wall(self, x: int, y: int) -> bool:
 95        """Reveal a secret wall tile, turning it into a corridor. Returns True if revealed."""
 96        if 0 <= x < self.width and 0 <= y < self.height and self.grid[y][x] == SECRET_WALL:
 97            self.grid[y][x] = CORRIDOR
 98            return True
 99        return False
100
101    def has_secret_wall_near(self, gx: int, gy: int, radius: int = 1) -> tuple[int, int] | None:
102        """Check if there's a secret wall within radius of grid position."""
103        for dy in range(-radius, radius + 1):
104            for dx in range(-radius, radius + 1):
105                nx, ny = gx + dx, gy + dy
106                if 0 <= nx < self.width and 0 <= ny < self.height:
107                    if self.grid[ny][nx] == SECRET_WALL:
108                        return (nx, ny)
109        return None
110
111
112def generate_dungeon(
113    width: int = 64,
114    height: int = 64,
115    min_rooms: int = 6,
116    max_rooms: int = 12,
117    min_room_size: int = 5,
118    max_room_size: int = 11,
119    seed: int | None = None,
120) -> DungeonData:
121    """Generate a dungeon using BSP-like room placement + corridor carving.
122
123    Args:
124        width: Grid width in tiles.
125        height: Grid height in tiles.
126        min_rooms: Minimum number of rooms.
127        max_rooms: Maximum number of rooms.
128        min_room_size: Minimum room dimension (tiles).
129        max_room_size: Maximum room dimension (tiles).
130        seed: Random seed for reproducibility.
131
132    Returns:
133        DungeonData with grid, rooms, entrance, and exit positions.
134    """
135    rng = random.Random(seed)
136
137    # Start with all walls
138    grid = [[WALL] * width for _ in range(height)]
139    rooms: list[Room] = []
140
141    # Place rooms
142    attempts = 0
143    target = rng.randint(min_rooms, max_rooms)
144    while len(rooms) < target and attempts < 500:
145        attempts += 1
146        rw = rng.randint(min_room_size, max_room_size)
147        rh = rng.randint(min_room_size, max_room_size)
148        if rw + 4 > width or rh + 4 > height:
149            continue
150        rx = rng.randint(2, width - rw - 2)
151        ry = rng.randint(2, height - rh - 2)
152        room = Room(rx, ry, rw, rh)
153        if any(room.intersects(r, padding=2) for r in rooms):
154            continue
155        rooms.append(room)
156        # Carve floor
157        for y in range(room.y, room.y + room.h):
158            for x in range(room.x, room.x + room.w):
159                grid[y][x] = FLOOR
160
161    # Sort rooms by position for corridor connectivity
162    rooms.sort(key=lambda r: (r.cx + r.cy))
163
164    # Connect rooms with L-shaped corridors
165    for i in range(len(rooms) - 1):
166        _carve_corridor(grid, rooms[i].centre, rooms[i + 1].centre, rng)
167
168    # Place entrance and exit in first and last rooms
169    entrance = rooms[0].centre
170    exit_pos = rooms[-1].centre
171    grid[entrance[1]][entrance[0]] = ENTRANCE
172    grid[exit_pos[1]][exit_pos[0]] = EXIT
173
174    # Secret rooms: 5% chance per eligible room
175    _try_place_secret_rooms(grid, rooms, width, height, rng)
176
177    return DungeonData(
178        width=width,
179        height=height,
180        grid=grid,
181        rooms=rooms,
182        entrance=entrance,
183        exit=exit_pos,
184    )
185
186
187def _try_place_secret_rooms(
188    grid: list[list[int]],
189    rooms: list[Room],
190    width: int,
191    height: int,
192    rng: random.Random,
193) -> None:
194    """Try to place secret rooms adjacent to existing rooms (5% chance each)."""
195    for room in rooms:
196        if rng.random() > 0.05:
197            continue
198        # Try each side of the room
199        sides = ["north", "south", "east", "west"]
200        rng.shuffle(sides)
201        for side in sides:
202            placed = _place_secret_room(grid, room, side, width, height, rng)
203            if placed:
204                break
205
206
207def _place_secret_room(
208    grid: list[list[int]],
209    parent_room: Room,
210    side: str,
211    width: int,
212    height: int,
213    rng: random.Random,
214) -> bool:
215    """Try to carve a small secret room on one side of a parent room.
216
217    Returns True if placement succeeded.
218    """
219    sw, sh = 4, 4  # Secret room size
220    if side == "north":
221        sx = parent_room.x + parent_room.w // 2 - sw // 2
222        sy = parent_room.y - sh - 1
223        door_x, door_y = parent_room.x + parent_room.w // 2, parent_room.y - 1
224    elif side == "south":
225        sx = parent_room.x + parent_room.w // 2 - sw // 2
226        sy = parent_room.y + parent_room.h + 1
227        door_x, door_y = parent_room.x + parent_room.w // 2, parent_room.y + parent_room.h
228    elif side == "east":
229        sx = parent_room.x + parent_room.w + 1
230        sy = parent_room.y + parent_room.h // 2 - sh // 2
231        door_x, door_y = parent_room.x + parent_room.w, parent_room.y + parent_room.h // 2
232    else:  # west
233        sx = parent_room.x - sw - 1
234        sy = parent_room.y + parent_room.h // 2 - sh // 2
235        door_x, door_y = parent_room.x - 1, parent_room.y + parent_room.h // 2
236
237    # Bounds check
238    if sx < 1 or sy < 1 or sx + sw >= width - 1 or sy + sh >= height - 1:
239        return False
240    if door_x < 0 or door_y < 0 or door_x >= width or door_y >= height:
241        return False
242
243    # Check area is all walls (don't overlap existing rooms)
244    for y in range(sy, sy + sh):
245        for x in range(sx, sx + sw):
246            if grid[y][x] != WALL:
247                return False
248
249    # Carve the secret room
250    for y in range(sy, sy + sh):
251        for x in range(sx, sx + sw):
252            grid[y][x] = FLOOR
253    # Place secret wall door
254    grid[door_y][door_x] = SECRET_WALL
255    return True
256
257
258def _carve_corridor(
259    grid: list[list[int]],
260    start: tuple[int, int],
261    end: tuple[int, int],
262    rng: random.Random,
263) -> None:
264    """Carve an L-shaped corridor between two points."""
265    x1, y1 = start
266    x2, y2 = end
267
268    # Randomly choose horizontal-first or vertical-first
269    if rng.random() < 0.5:
270        _carve_h(grid, x1, x2, y1)
271        _carve_v(grid, y1, y2, x2)
272    else:
273        _carve_v(grid, y1, y2, x1)
274        _carve_h(grid, x1, x2, y2)
275
276
277def _carve_h(grid: list[list[int]], x1: int, x2: int, y: int) -> None:
278    """Carve horizontal corridor (3 tiles wide)."""
279    for x in range(min(x1, x2), max(x1, x2) + 1):
280        for dy in (-1, 0, 1):
281            ny = y + dy
282            if 0 <= ny < len(grid) and 0 <= x < len(grid[0]):
283                if grid[ny][x] == WALL:
284                    grid[ny][x] = CORRIDOR
285
286
287def _carve_v(grid: list[list[int]], y1: int, y2: int, x: int) -> None:
288    """Carve vertical corridor (3 tiles wide)."""
289    for y in range(min(y1, y2), max(y1, y2) + 1):
290        for dx in (-1, 0, 1):
291            nx = x + dx
292            if 0 <= y < len(grid) and 0 <= nx < len(grid[0]):
293                if grid[y][nx] == WALL:
294                    grid[y][nx] = CORRIDOR
295
296
297def build_pathfinding_grid(data: DungeonData) -> NavGrid2D:
298    """Build an NavGrid2D from dungeon data for enemy pathfinding."""
299    grid = NavGrid2D(
300        width=data.width,
301        height=data.height,
302        cell_size=float(TILE_SIZE),
303        # Enemies have a body radius, so a diagonal is only usable when both
304        # cells beside it are floor. Otherwise they would path through the
305        # corner where two walls meet and stick on it.
306        corners="strict",
307    )
308    for y in range(data.height):
309        for x in range(data.width):
310            if not data.is_walkable(x, y):
311                grid.set_solid(x, y)
312    return grid
313
314
315def path_exists(data: DungeonData) -> bool:
316    """Verify a walkable path exists from entrance to exit using BFS."""
317    if data.entrance == data.exit:
318        return True
319    visited: set[tuple[int, int]] = set()
320    queue = [data.entrance]
321    visited.add(data.entrance)
322    while queue:
323        cx, cy = queue.pop(0)
324        for dx, dy in ((0, 1), (0, -1), (1, 0), (-1, 0)):
325            nx, ny = cx + dx, cy + dy
326            if (nx, ny) not in visited and data.is_walkable(nx, ny):
327                if (nx, ny) == data.exit:
328                    return True
329                visited.add((nx, ny))
330                queue.append((nx, ny))
331    return False
332
333
334# ============================================================================
335# TileMap + collision node construction
336# ============================================================================
337
338# Colour palette for tile types: walls are background, floors are lit
339COLOURS = {
340    FLOOR: (0.35, 0.30, 0.25, 1.0),  # Warm stone floor
341    WALL: (0.12, 0.10, 0.08, 1.0),  # Very dark (nearly invisible: bg)
342    ENTRANCE: (0.25, 0.55, 0.30, 1.0),  # Green glow
343    EXIT: (0.65, 0.20, 0.20, 1.0),  # Red glow
344    CORRIDOR: (0.30, 0.26, 0.22, 1.0),  # Slightly darker than floor
345    SECRET_WALL: (0.14, 0.12, 0.10, 1.0),  # Subtle difference from regular wall
346}
347
348# ----------------------------------------------------------------------------
349# Procedural floor tileset (instanced)
350#
351# The whole floor renders as a handful of instanced GPU draws via a TileMap
352# rather than thousands of per-cell ``draw_rect`` calls every redraw. The look
353# (per-tile base colour, wall-edge shadows, floor details, boss-room border) is
354# baked into a tiny procedural atlas so the SoA per-tile-colour path carries the
355# tint and the atlas carries the sub-tile shading. A white texel multiplied by
356# the per-tile tint reproduces the old flat ``draw_rect(colour)`` exactly; the
357# baked edge shadow is a multiplier (1 - alpha of the old black overlay), so a
358# lit interior tile is pixel-identical and only the shaded rims differ slightly.
359# ----------------------------------------------------------------------------
360
361# Edge-shadow autotile bits (which sides border a non-walkable cell).
362_EDGE_L, _EDGE_R, _EDGE_T, _EDGE_B = 1, 2, 4, 8
363# Tile-id layout inside the atlas tileset.
364_FLOOR_BASE = 0  # ids 0..15: floor, indexed by the 4-bit wall-edge mask
365_DETAIL_CRACK = 16
366_DETAIL_STAIN = 17
367_DETAIL_MOSS = 18
368_BORDER_BASE = 19  # ids 19..34: boss-room border, indexed by edge mask
369_DETAIL_IDS = {"crack": _DETAIL_CRACK, "stain": _DETAIL_STAIN, "moss": _DETAIL_MOSS}
370
371# Fog-of-war tint plane, indexed by visibility state (0/1/2). Non-visible cells
372# tint the white ``_FLOOR_BASE`` tile to black: opaque when unexplored, 55% when
373# explored-but-dark; visible cells get zero alpha (and no tile, see _build_floor).
374# This reproduces the retired per-cell ``draw_rect`` fog as one instanced layer.
375_FOG_TINT = np.array(
376    [
377        [0.0, 0.0, 0.0, 1.0],  # 0 unexplored: opaque black
378        [0.0, 0.0, 0.0, 0.55],  # 1 explored, not currently visible: dim
379        [0.0, 0.0, 0.0, 0.0],  # 2 visible: cleared (tile id -1, alpha unused)
380    ],
381    dtype=np.float32,
382)
383
384_ATLAS: tuple[np.ndarray, int, int, dict[int, tuple[int, int, int, int]]] | None = None
385
386
387def _disc(rgba: np.ndarray, cx: int, cy: int, r: int, colour: tuple[float, float, float, float]) -> None:
388    """Stamp a filled disc of ``colour`` (RGBA 0-1) into an RGBA uint8 tile."""
389    h, w = rgba.shape[:2]
390    yy, xx = np.ogrid[:h, :w]
391    mask = (xx - cx) ** 2 + (yy - cy) ** 2 <= r * r
392    rgba[mask] = np.array([c * 255 for c in colour], dtype=np.uint8)
393
394
395def _build_floor_atlas() -> tuple[np.ndarray, int, int, dict[int, tuple[int, int, int, int]]]:
396    """Build (once) the RGBA floor atlas + per-variant pixel regions.
397
398    Each variant is a ``TILE_SIZE`` cell with a 1px replicated guard ring so the
399    bindless linear sampler never bleeds across atlas neighbours. Returns the
400    pixel buffer, its width/height, and ``{tile_id: (x, y, w, h)}`` regions.
401    """
402    ts = TILE_SIZE
403    guard = 1
404    pitch = ts + 2 * guard
405    cols = 8
406
407    # 16 edge-shadow floor + 3 detail + 16 boss-border variants.
408    variants: dict[int, np.ndarray] = {}
409
410    # Floor: white base, darkened rims where a side borders a wall. The rim
411    # multipliers mirror the retired overlays: a 1px line at 0.30 (black @0.70)
412    # and a 3px band at 0.75 (black @0.25). ``rows`` selects the [edge, 1:4 band]
413    # slabs along whichever axis the flagged side runs.
414    def _rim(mult: np.ndarray, line, band) -> None:
415        mult[line] = np.minimum(mult[line], 0.30)
416        mult[band] = np.minimum(mult[band], 0.75)
417
418    edge_slabs = {
419        _EDGE_L: ((slice(None), 0), (slice(None), slice(1, 4))),
420        _EDGE_R: ((slice(None), ts - 1), (slice(None), slice(ts - 4, ts - 1))),
421        _EDGE_T: ((0, slice(None)), (slice(1, 4), slice(None))),
422        _EDGE_B: ((ts - 1, slice(None)), (slice(ts - 4, ts - 1), slice(None))),
423    }
424    for mask in range(16):
425        mult = np.ones((ts, ts), dtype=np.float32)
426        for bit, (line, band) in edge_slabs.items():
427            if mask & bit:
428                _rim(mult, line, band)
429        cell = np.empty((ts, ts, 4), dtype=np.uint8)
430        cell[..., :3] = (mult[..., None] * 255).astype(np.uint8)
431        cell[..., 3] = 255
432        variants[_FLOOR_BASE + mask] = cell
433
434    # Floor details: transparent except the mark, blended over the floor layer.
435    crack = np.zeros((ts, ts, 4), dtype=np.uint8)
436    crack[ts // 2, 6 : ts - 6] = (0, 0, 0, 76)  # horizontal hairline (black @0.30)
437    crack[4 : ts - 4, ts // 2] = (0, 0, 0, 51)  # vertical hairline   (black @0.20)
438    variants[_DETAIL_CRACK] = crack
439    stain = np.zeros((ts, ts, 4), dtype=np.uint8)
440    _disc(stain, ts // 2, ts // 2, 4, (0.15, 0.12, 0.08, 0.30))
441    variants[_DETAIL_STAIN] = stain
442    moss = np.zeros((ts, ts, 4), dtype=np.uint8)
443    _disc(moss, 8, 8, 2, (0.15, 0.35, 0.12, 0.40))
444    _disc(moss, ts - 8, ts - 8, 2, (0.12, 0.30, 0.10, 0.35))
445    variants[_DETAIL_MOSS] = moss
446
447    # Boss-room border: a 2px red rim on the sides that face the room's outside.
448    red = (153, 38, 26, 128)  # (0.6, 0.15, 0.1, 0.5)
449    for mask in range(16):
450        cell = np.zeros((ts, ts, 4), dtype=np.uint8)
451        if mask & _EDGE_L:
452            cell[:, 0:2] = red
453        if mask & _EDGE_R:
454            cell[:, ts - 2 : ts] = red
455        if mask & _EDGE_T:
456            cell[0:2, :] = red
457        if mask & _EDGE_B:
458            cell[ts - 2 : ts, :] = red
459        variants[_BORDER_BASE + mask] = cell
460
461    rows = (max(variants) + cols) // cols
462    atlas = np.zeros((rows * pitch, cols * pitch, 4), dtype=np.uint8)
463    regions: dict[int, tuple[int, int, int, int]] = {}
464    for tid, cell in variants.items():
465        col, row = tid % cols, tid // cols
466        ix, iy = col * pitch + guard, row * pitch + guard
467        atlas[iy : iy + ts, ix : ix + ts] = cell
468        # Replicate edges into the guard ring so half-texel sampling stays in-cell.
469        atlas[iy - 1, ix : ix + ts] = cell[0, :]
470        atlas[iy + ts, ix : ix + ts] = cell[ts - 1, :]
471        atlas[iy : iy + ts, ix - 1] = cell[:, 0]
472        atlas[iy : iy + ts, ix + ts] = cell[:, ts - 1]
473        regions[tid] = (ix, iy, ts, ts)
474    return atlas, atlas.shape[1], atlas.shape[0], regions
475
476
477def _make_floor_tileset() -> TileSet:
478    """A fresh TileSet over the shared, build-once floor atlas.
479
480    A fresh instance per level keeps the GPU texture id off the cached pixels, so
481    a floor transition (or a new engine in-process) re-uploads cleanly rather
482    than reusing a stale bindless slot; the atlas pixels themselves are shared.
483    """
484    global _ATLAS
485    if _ATLAS is None:
486        _ATLAS = _build_floor_atlas()
487    pixels, w, h, regions = _ATLAS
488    tileset = TileSet(tile_size=(TILE_SIZE, TILE_SIZE))
489    tileset._atlas_pixels = pixels
490    tileset._atlas_width = w
491    tileset._atlas_height = h
492    for tid, region in regions.items():
493        tileset.set_tile(tid, TileData(texture_region=region))
494    return tileset
495
496
497def _hash_variation(x: int, y: int) -> float:
498    """Deterministic per-tile brightness jitter in [-0.03, +0.03]."""
499    return ((x * 7 + y * 13) % 100) / 100.0 * 0.06 - 0.03
500
501
502class WallBody(PhysicsBody2D):
503    """Static wall collision body: a single AABB covering contiguous wall tiles.
504
505    A ``PhysicsBody2D(mode=STATIC)``: the player / enemy ``CharacterBody2D``
506    nodes collide-and-slide against it via the shared 2D world's broadphase, so
507    no per-body overlap poll is needed any more.
508
509    It carries the world layer and a mask naming the actor layers, which is the
510    wall's half of the AND rule. The actors are on their own layers so they do not
511    block each other, so the wall cannot rely on a default mask to see them.
512    """
513
514    # Procedural; layout is regenerated from the dungeon seed on load.
515    __save_persist__ = False
516
517    def __init__(self, x: float, y: float, w: float, h: float, **kwargs):
518        kwargs.setdefault("collision_layer", LAYER_WORLD)
519        kwargs.setdefault("collision_mask", MASK_ACTORS)
520        super().__init__(
521            mode=BodyMode.STATIC,
522            shape=RectangleShape2D(half_extents=Vec2(w / 2, h / 2)),
523            **kwargs,
524        )
525        self.position = Vec2(x + w / 2, y + h / 2)
526        self._w = w
527        self._h = h
528
529
530class DungeonLevel(Node2D):
531    """A complete dungeon level node containing floor rendering, wall collision, and metadata.
532
533    Attributes:
534        dungeon_data: The generated DungeonData.
535        nav_grid: NavGrid2D for pathfinding.
536    """
537
538    # Procedural; layout is regenerated from the dungeon seed on load.
539    __save_persist__ = False
540
541    dungeon_level = Property(1, range=(1, 100), hint="Current dungeon depth")
542
543    def __init__(self, data: DungeonData, level: int = 1, theme: dict | None = None, **kwargs):
544        super().__init__(name="DungeonLevel", **kwargs)
545        self.dungeon_data = data
546        self.dungeon_level = level
547        self.nav_grid = build_pathfinding_grid(data)
548        self._theme = theme
549        self._elapsed = 0.0
550        self._player_gx: int | None = None
551        self._player_gy: int | None = None
552        # Pre-compute floor details and torch positions
553        self._floor_details = _generate_floor_details(data, level)
554        self._torch_positions = _find_torch_positions(data)
555        # Pre-compute which room each cell belongs to
556        self._room_index = _build_room_index(data)
557        self._floor_tilemap: TileMap | None = None
558        # Fog-of-war: the dimming overlay is a layer of the floor TileMap (one
559        # instanced draw, recomputed only on fog change). ``_fog`` is the live
560        # FogOfWar, shared so the dynamic torch/stair overlay can self-cull.
561        self._fog = None
562        self._fog_layer = 3
563        self._build_wall_bodies()
564        # Animated torch flicker + entrance/exit stair glow ride a separate
565        # ``dynamic`` child: the static floor is now an instanced TileMap (one
566        # draw per layer, built once), while these few pulsing shapes redraw
567        # every frame without touching DungeonLevel.on_draw.
568        self.add_child(_DungeonOverlay(self))
569
570    def on_ready(self) -> None:
571        """Build the instanced floor TileMap once, on entering the tree."""
572        if self._floor_tilemap is None:
573            self._build_floor()
574
575    def _build_wall_bodies(self) -> None:
576        """Create merged wall collision bodies from the grid.
577
578        Scans rows for contiguous wall runs and creates one AABB per run.
579        """
580        data = self.dungeon_data
581        visited = [[False] * data.width for _ in range(data.height)]
582
583        for y in range(data.height):
584            x = 0
585            while x < data.width:
586                if not data.is_walkable(x, y) and not visited[y][x]:
587                    # Find horizontal run of wall tiles
588                    x_end = x
589                    while x_end < data.width and not data.is_walkable(x_end, y) and not visited[y][x_end]:
590                        x_end += 1
591                    run_w = x_end - x
592                    # Try to extend downward for a rectangular block
593                    y_end = y + 1
594                    while y_end < data.height:
595                        ok = True
596                        for xx in range(x, x_end):
597                            if data.is_walkable(xx, y_end) or visited[y_end][xx]:
598                                ok = False
599                                break
600                        if not ok:
601                            break
602                        y_end += 1
603                    run_h = y_end - y
604                    # Mark visited
605                    for yy in range(y, y_end):
606                        for xx in range(x, x_end):
607                            visited[yy][xx] = True
608                    # Create wall body
609                    wx = x * TILE_SIZE
610                    wy = y * TILE_SIZE
611                    ww = run_w * TILE_SIZE
612                    wh = run_h * TILE_SIZE
613                    self.add_child(WallBody(wx, wy, ww, wh, name=f"Wall_{x}_{y}"))
614                    x = x_end
615                else:
616                    x += 1
617
618    def rebuild_wall_bodies(self) -> None:
619        """Rebuild all wall collision bodies (call after revealing secret walls)."""
620        for child in list(self.children):
621            if isinstance(child, WallBody):
622                child.destroy()
623        self._build_wall_bodies()
624
625    def set_player_grid_pos(self, gx: int, gy: int) -> None:
626        """Update cached player grid position for proximity effects."""
627        self._player_gx = gx
628        self._player_gy = gy
629
630    def on_update(self, dt: float) -> None:
631        self._elapsed += dt
632
633    # ------------------------------------------------------------------------
634    # Floor: one instanced TileMap, built once, no per-frame Python.
635    # ------------------------------------------------------------------------
636
637    def _edge_mask(self, x: int, y: int) -> int:
638        """4-bit wall-adjacency mask (L/R/T/B) selecting the floor shadow variant."""
639        d = self.dungeon_data
640        mask = 0
641        if not d.is_walkable(x - 1, y):
642            mask |= _EDGE_L
643        if not d.is_walkable(x + 1, y):
644            mask |= _EDGE_R
645        if not d.is_walkable(x, y - 1):
646            mask |= _EDGE_T
647        if not d.is_walkable(x, y + 1):
648            mask |= _EDGE_B
649        return mask
650
651    def _build_floor(self) -> None:
652        """Bake the whole floor into one instanced TileMap (vectorised, O(chunks)).
653
654        Layer 0 = floor base + wall-edge shadow (per-tile tint = base colour),
655        layer 1 = floor details, layer 2 = boss-room border. All authored with
656        ``set_cells`` so the per-frame cost is the engine's instanced submit, not
657        a Python loop. Layer 3 = fog-of-war dimming, filled by :meth:`update_fog`.
658        """
659        data = self.dungeon_data
660        h, w = data.height, data.width
661        grid = np.array(data.grid, dtype=np.int32)
662        walk = np.isin(grid, (FLOOR, ENTRANCE, EXIT, CORRIDOR))  # excludes SECRET_WALL/WALL
663
664        # Wall-edge shadow mask per cell: a side's bit is set when that neighbour
665        # is not walkable (an off-grid neighbour, via the zero pad, counts as wall).
666        lw = np.zeros_like(walk)
667        rw = np.zeros_like(walk)
668        tw = np.zeros_like(walk)
669        bw = np.zeros_like(walk)
670        lw[:, 1:] = walk[:, :-1]
671        rw[:, :-1] = walk[:, 1:]
672        tw[1:, :] = walk[:-1, :]
673        bw[:-1, :] = walk[1:, :]
674        edge = ((~lw) * _EDGE_L + (~rw) * _EDGE_R + (~tw) * _EDGE_T + (~bw) * _EDGE_B).astype(np.int32)
675
676        tile_ids = np.full((h, w), -1, dtype=np.int32)
677        tile_ids[walk] = _FLOOR_BASE + edge[walk]
678        tile_ids[grid == SECRET_WALL] = _FLOOR_BASE  # flat wall-hint cell, no rim
679
680        # Base colours + per-tile variation + entrance/exit room brightness.
681        colours = np.zeros((h, w, 4), dtype=np.float32)
682        for cell, c in COLOURS.items():
683            colours[grid == cell] = c
684        room_of = np.full((h, w), -1, dtype=np.int32)
685        for i, room in enumerate(data.rooms):
686            room_of[room.y : room.y + room.h, room.x : room.x + room.w] = i
687        entrance_room = self._room_index.get(data.entrance)
688        exit_room = self._room_index.get(data.exit)
689        if entrance_room is not None:
690            m = room_of == entrance_room
691            colours[m, :3] = np.clip(colours[m, :3] + 0.06, 0.0, 1.0)
692        if exit_room is not None and exit_room != entrance_room:
693            m = room_of == exit_room
694            colours[m, :3] = np.clip(colours[m, :3] - 0.06, 0.0, 1.0)
695        xs = np.arange(w, dtype=np.float32)[None, :]
696        ys = np.arange(h, dtype=np.float32)[:, None]
697        var = ((xs * 7 + ys * 13) % 100) / 100.0 * 0.06 - 0.03
698        colours[..., :3] = np.clip(colours[..., :3] + var[..., None], 0.0, 1.0)
699        colours[grid == SECRET_WALL] = COLOURS[SECRET_WALL]  # flat, no variation
700
701        tm = TileMap(name="Floor", cell_size=(TILE_SIZE, TILE_SIZE), tile_set=_make_floor_tileset())
702        tm.add_layer("details")
703        tm.add_layer("border")
704        # Fog layer (topmost): empty until ``update_fog`` writes per-tile alpha.
705        # It reuses the white ``_FLOOR_BASE`` tile, tinted black, so the dimming
706        # is one instanced draw layered above the floor with no extra texture.
707        self._fog_layer = tm.add_layer("fog")
708        tm.get_layer(0).set_cells(tile_ids, colours=colours)
709
710        # Layer 1: floor details (transparent marks over the floor), tint white.
711        detail_ids = np.full((h, w), -1, dtype=np.int32)
712        for (dx, dy), kind in self._floor_details.items():
713            detail_ids[dy, dx] = _DETAIL_IDS[kind]
714        tm.get_layer(1).set_cells(detail_ids)
715
716        # Layer 2: boss-room arena border (last room, unless it is the entrance).
717        boss_room = len(data.rooms) - 1 if data.rooms else -1
718        if boss_room >= 0 and boss_room != entrance_room:
719            room = data.rooms[boss_room]
720            border_ids = np.full((h, w), -1, dtype=np.int32)
721            for by in range(room.y, room.y + room.h):
722                for bx in range(room.x, room.x + room.w):
723                    bmask = 0
724                    if bx == room.x:
725                        bmask |= _EDGE_L
726                    if bx == room.x + room.w - 1:
727                        bmask |= _EDGE_R
728                    if by == room.y:
729                        bmask |= _EDGE_T
730                    if by == room.y + room.h - 1:
731                        bmask |= _EDGE_B
732                    if bmask:
733                        border_ids[by, bx] = _BORDER_BASE + bmask
734            tm.get_layer(2).set_cells(border_ids)
735
736        self.add_child(tm)
737        self._floor_tilemap = tm
738        if self._fog is not None:
739            self.update_fog(self._fog)
740
741    def update_fog(self, fog) -> None:
742        """Rewrite the instanced fog layer from the per-tile visibility state.
743
744        Called ONLY when the fog changes (the player steps to a new tile), never
745        per frame: the layer is one retained instanced draw and the GPU discards
746        off-screen tiles, so a camera scroll is a uniform-only update. Non-visible
747        cells get a black ``_FLOOR_BASE`` tile (opaque unexplored, 55% explored);
748        visible cells are cleared to ``-1``. Fully vectorised (O(grid) numpy, no
749        per-cell loop), so even a refresh stays cheap and per-frame cost is zero.
750
751        Also caches the live ``fog`` so the dynamic torch/stair overlay can
752        self-cull in non-visible tiles (the fog layer sits below the 2D overlay,
753        so dimming alone can't hide them: every dynamic element culls itself,
754        exactly like enemies/projectiles/objects do).
755        """
756        self._fog = fog
757        if self._floor_tilemap is None or fog is None:
758            return
759        state = fog.state_array()  # (H, W) int, 0/1/2
760        tile_ids = np.where(state == 2, -1, _FLOOR_BASE).astype(np.int32)
761        self._floor_tilemap.get_layer(self._fog_layer).set_cells(tile_ids, colours=_FOG_TINT[state])
762
763    def reveal_floor_cell(self, x: int, y: int) -> None:
764        """Re-tile a revealed secret-wall cell (now a corridor) and its neighbours.
765
766        The grid was already mutated to ``CORRIDOR`` by ``reveal_secret_wall``;
767        this updates only the handful of affected tiles in the instanced floor
768        (no full redraw).
769        """
770        if self._floor_tilemap is None:
771            return
772        layer = self._floor_tilemap.get_layer(0)
773        var = _hash_variation(x, y)
774        base = COLOURS[CORRIDOR]
775        colour = (*(min(1.0, max(0.0, base[i] + var)) for i in range(3)), base[3])
776        layer.set_cell(x, y, _FLOOR_BASE + self._edge_mask(x, y), colour=colour)
777        for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
778            if self.dungeon_data.is_walkable(nx, ny):
779                layer.set_cell(nx, ny, _FLOOR_BASE + self._edge_mask(nx, ny))
780
781    def _draw_stairs(self, renderer, grid_pos: tuple[int, int], is_entrance: bool) -> None:
782        """Draw stacked-rect perspective stairs with a glow and pulse."""
783        gx, gy = grid_pos
784        # Self-cull in fog: the dimming layer sits below this 2D overlay, so the
785        # stairs would otherwise shine through unexplored black. Hidden until the
786        # tile is currently visible (consistent with enemies / objects).
787        if self._fog is not None and not self._fog.is_visible(gx, gy):
788            return
789        ts = TILE_SIZE
790        cx, cy = gx * ts + ts // 2, gy * ts + ts // 2
791
792        # Stacked rects (3 levels) for depth effect
793        for i in range(3):
794            off = (2 - i) * 3
795            w = ts - 4 - i * 4
796            h = ts - 4 - i * 4
797            shade = 0.15 + i * 0.08
798            renderer.draw_rect(
799                (cx - w // 2 + off, cy - h // 2 + off), (w, h), colour=(shade, shade, shade, 0.9), filled=True
800            )
801
802        # Glow colour: green for entrance, red for exit
803        if is_entrance:
804            glow = (0.2, 0.8, 0.3, 0.5)
805        else:
806            glow = (0.8, 0.2, 0.15, 0.5)
807
808        # Pulse when player is near
809        if self._player_gx is not None:
810            dx = abs(self._player_gx - gx)
811            dy = abs(self._player_gy - gy)
812            if dx <= 3 and dy <= 3:
813                pulse = 0.3 + 0.2 * math.sin(self._elapsed * 4.0)
814                glow = (glow[0], glow[1], glow[2], pulse + 0.3)
815
816        renderer.draw_circle((cx, cy), 6, colour=glow, filled=True)
817
818    def _draw_torches(self, renderer) -> None:
819        """Draw ambient torches: orange flicker at room entrances."""
820        fog = self._fog
821        for tx, ty in self._torch_positions:
822            # Self-cull in fog (see _draw_stairs): torches in non-visible tiles
823            # are hidden, since the dimming layer sits below this 2D overlay.
824            if fog is not None and not fog.is_visible(tx, ty):
825                continue
826            ts = TILE_SIZE
827            px, py_ = tx * ts + ts // 2, ty * ts + ts // 2
828            # Flicker using time + position hash for variation
829            flicker = 0.7 + 0.3 * math.sin(self._elapsed * 6.0 + tx * 3.7 + ty * 2.3)
830            # Torch holder (small dark rect)
831            renderer.draw_rect((px - 2, py_ - 4), (4, 8), colour=(0.25, 0.15, 0.05, 0.9), filled=True)
832            # Flame
833            renderer.draw_circle((px, py_ - 5), 3, colour=(1.0, 0.6 * flicker, 0.1, 0.9), filled=True)
834            # Local brightness glow
835            glow_r = 18 * flicker
836            renderer.draw_circle((px, py_), glow_r, colour=(1.0, 0.5, 0.1, 0.08 * flicker), filled=True)
837
838    def world_pos_of(self, grid_x: int, grid_y: int) -> Vec2:
839        """Convert grid coords to world position (centre of tile)."""
840        return Vec2(grid_x * TILE_SIZE + TILE_SIZE / 2, grid_y * TILE_SIZE + TILE_SIZE / 2)
841
842    def entrance_world_pos(self) -> Vec2:
843        return self.world_pos_of(*self.dungeon_data.entrance)
844
845    def exit_world_pos(self) -> Vec2:
846        return self.world_pos_of(*self.dungeon_data.exit)
847
848
849class _DungeonOverlay(Node2D):
850    """Per-frame pulsing overlay for a :class:`DungeonLevel`: torches + stairs.
851
852    Torch flame brightness and the near-player stair glow both read
853    ``parent._elapsed`` (non-Property state) and produce different geometry every
854    frame: honestly ``dynamic``. Keeping these few shapes here lets the static
855    floor live in the instanced TileMap (one draw per layer) while only this
856    handful of draws re-emits each frame.
857    """
858
859    # Transient view node, rebuilt with each dungeon: never persisted.
860    __save_persist__ = False
861    dynamic = True
862
863    def __init__(self, level: DungeonLevel, **kwargs):
864        super().__init__(name="DungeonOverlay", **kwargs)
865        self._level = level
866
867    def on_draw(self, renderer) -> None:
868        lvl = self._level
869        data = lvl.dungeon_data
870        lvl._draw_stairs(renderer, data.entrance, is_entrance=True)
871        lvl._draw_stairs(renderer, data.exit, is_entrance=False)
872        lvl._draw_torches(renderer)
873
874
875# ============================================================================
876# Environmental visual helpers
877# ============================================================================
878
879
880def _generate_floor_details(data: DungeonData, level: int) -> dict[tuple[int, int], str]:
881    """Procedurally place floor details on ~10% of floor tiles."""
882    rng = random.Random(level * 9973)
883    details: dict[tuple[int, int], str] = {}
884    types = ["crack", "crack", "stain", "stain", "moss"]
885    for y in range(data.height):
886        for x in range(data.width):
887            if data.is_walkable(x, y) and data.grid[y][x] in (FLOOR, CORRIDOR):
888                if rng.random() < 0.10:
889                    details[(x, y)] = rng.choice(types)
890    return details
891
892
893def _find_torch_positions(data: DungeonData) -> list[tuple[int, int]]:
894    """Find positions for ambient torches at room entrances.
895
896    Places torches on wall tiles adjacent to a room's first walkable opening.
897    """
898    torches: list[tuple[int, int]] = []
899    for room in data.rooms:
900        # Check room perimeter for openings into corridors
901        for x in range(room.x, room.x + room.w):
902            for y_edge, _dy in ((room.y - 1, -1), (room.y + room.h, 1)):
903                if data.is_walkable(x, y_edge):
904                    # Place torch on the wall tile flanking the opening
905                    for side_dx in (-1, 1):
906                        tx = x + side_dx
907                        if 0 <= tx < data.width and 0 <= y_edge < data.height:
908                            if not data.is_walkable(tx, y_edge) and (tx, y_edge) not in torches:
909                                torches.append((tx, y_edge))
910                                break
911                    break
912        for y in range(room.y, room.y + room.h):
913            for x_edge, _dx in ((room.x - 1, -1), (room.x + room.w, 1)):
914                if data.is_walkable(x_edge, y):
915                    for side_dy in (-1, 1):
916                        ty = y + side_dy
917                        if 0 <= ty < data.height and 0 <= x_edge < data.width:
918                            if not data.is_walkable(x_edge, ty) and (x_edge, ty) not in torches:
919                                torches.append((x_edge, ty))
920                                break
921                    break
922    return torches
923
924
925def _build_room_index(data: DungeonData) -> dict[tuple[int, int], int]:
926    """Map each cell inside a room to its room index."""
927    index: dict[tuple[int, int], int] = {}
928    for i, room in enumerate(data.rooms):
929        for y in range(room.y, room.y + room.h):
930            for x in range(room.x, room.x + room.w):
931                index[(x, y)] = i
932    return index