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