Procedural Dungeon¶
rooms and corridors on a TileMap.
▶ Run in browserTags: 2d tilemap procgen dungeon collision
generate_dungeon(width, height, seed) is a plain function you can lift into a
game. It seeds a random.Random, tries a number of random rectangles, keeps
each one that leaves a one-cell wall gap to every room already placed, and
joins it to the previously kept room with an L-shaped corridor. Because a room
is only ever linked to one that is already connected, the whole floor is one
reachable region with no repair pass, and the same seed always gives the same
dungeon. The result is a (height, width) numpy grid of FLOOR and WALL
plus the room list.
The grid values are the tile ids of a two-tile atlas built with numpy and
TileSet.from_atlas_array, so TileMap.get_layer(0).set_cells(grid) draws
the whole map in one vectorised call, which is what makes regenerating
instant.
The playfield is a fixed design resolution, the map plus the status strip below it, scaled to fit the window, so the same seed shows the same whole map at any window size.
The player collides against the grid rather than against physics bodies:
can_stand checks that every cell under the player’s box is floor, and the
move is tried one axis at a time so a blocked axis slides along the wall.
Controls: WASD, the arrow keys or the left stick move. R, a click or a tap
rolls a new seed; [ and ] step to the previous and next seed.
Source¶
1# /// simvx
2# tags = ["2d", "tilemap", "procgen", "dungeon", "collision"]
3# web = { width = 960, height = 540 }
4# ///
5"""Procedural Dungeon: rooms and corridors on a TileMap.
6
7`generate_dungeon(width, height, seed)` is a plain function you can lift into a
8game. It seeds a `random.Random`, tries a number of random rectangles, keeps
9each one that leaves a one-cell wall gap to every room already placed, and
10joins it to the previously kept room with an L-shaped corridor. Because a room
11is only ever linked to one that is already connected, the whole floor is one
12reachable region with no repair pass, and the same seed always gives the same
13dungeon. The result is a `(height, width)` numpy grid of `FLOOR` and `WALL`
14plus the room list.
15
16The grid values are the tile ids of a two-tile atlas built with numpy and
17`TileSet.from_atlas_array`, so `TileMap.get_layer(0).set_cells(grid)` draws
18the whole map in one vectorised call, which is what makes regenerating
19instant.
20
21The playfield is a fixed design resolution, the map plus the status strip
22below it, scaled to fit the window, so the same seed shows the same whole map
23at any window size.
24
25The player collides against the grid rather than against physics bodies:
26`can_stand` checks that every cell under the player's box is floor, and the
27move is tried one axis at a time so a blocked axis slides along the wall.
28
29Controls: WASD, the arrow keys or the left stick move. R, a click or a tap
30rolls a new seed; `[` and `]` step to the previous and next seed.
31"""
32
33import random
34from dataclasses import dataclass
35
36import numpy as np
37
38from simvx.core import (
39 Anchor,
40 Axis2D,
41 CanvasLayer,
42 ContentScaleMode,
43 Controller,
44 Input,
45 Key,
46 Label,
47 Node2D,
48 Pointer,
49 Sprite2D,
50 TileMap,
51 TileSet,
52 on_input,
53)
54from simvx.graphics import App
55
56TILE = 16
57GRID_W, GRID_H = 60, 32
58HUD_HEIGHT = 28 # the status strip below the map
59FLOOR, WALL = 0, 1 # grid values and tile ids alike: the atlas is built in this order
60PLAYER_SIZE = 12
61PLAYER_HALF = 5.0 # half-extent of the collision box, a little inside the sprite
62PLAYER_SPEED = 150.0
63
64
65@dataclass(frozen=True)
66class Room:
67 x: int
68 y: int
69 w: int
70 h: int
71
72 @property
73 def centre(self) -> tuple[int, int]:
74 return self.x + self.w // 2, self.y + self.h // 2
75
76 def near(self, other: Room) -> bool:
77 """True if the rooms overlap or touch, counting a one-cell margin."""
78 return (
79 self.x - 1 < other.x + other.w
80 and other.x < self.x + self.w + 1
81 and self.y - 1 < other.y + other.h
82 and other.y < self.y + self.h + 1
83 )
84
85
86@dataclass(frozen=True)
87class Dungeon:
88 grid: np.ndarray # (height, width) uint8 of FLOOR / WALL
89 rooms: list[Room]
90 seed: int
91
92
93def generate_dungeon(
94 width: int, height: int, seed: int, *, attempts: int = 40, room_min: int = 4, room_max: int = 8
95) -> Dungeon:
96 """Place non-touching rooms at random and join each to the previous one."""
97 rng = random.Random(seed)
98 grid = np.full((height, width), WALL, dtype=np.uint8)
99 rooms: list[Room] = []
100 for _ in range(attempts):
101 w, h = rng.randint(room_min, room_max), rng.randint(room_min, room_max)
102 room = Room(rng.randint(1, width - w - 1), rng.randint(1, height - h - 1), w, h)
103 if any(room.near(r) for r in rooms):
104 continue
105 grid[room.y : room.y + room.h, room.x : room.x + room.w] = FLOOR
106 if rooms:
107 carve_corridor(grid, room.centre, rooms[-1].centre, rng)
108 rooms.append(room)
109 return Dungeon(grid, rooms, seed)
110
111
112def carve_corridor(grid: np.ndarray, a: tuple[int, int], b: tuple[int, int], rng: random.Random) -> None:
113 """Carve an L-shaped corridor from `a` to `b`, turning at a random corner."""
114 (ax, ay), (bx, by) = a, b
115 corner = (bx, ay) if rng.random() < 0.5 else (ax, by)
116 for (x0, y0), (x1, y1) in ((a, corner), (corner, b)):
117 grid[min(y0, y1) : max(y0, y1) + 1, min(x0, x1) : max(x0, x1) + 1] = FLOOR
118
119
120def can_stand(grid: np.ndarray, x: float, y: float, half: float = PLAYER_HALF) -> bool:
121 """True if a box of half-extent `half` centred on world point (x, y) covers only floor."""
122 x0, x1 = int((x - half) // TILE), int((x + half) // TILE)
123 y0, y1 = int((y - half) // TILE), int((y + half) // TILE)
124 h, w = grid.shape
125 if x0 < 0 or y0 < 0 or x1 >= w or y1 >= h:
126 return False
127 return bool(np.all(grid[y0 : y1 + 1, x0 : x1 + 1] == FLOOR))
128
129
130def make_atlas() -> np.ndarray:
131 """Two noisy tiles side by side: dark floor (id 0), then blue-grey wall (id 1) with mortar edges."""
132 rng = np.random.default_rng(11)
133 atlas = np.full((TILE, TILE * 2, 4), 255, dtype=np.uint8)
134 for tile_id, base in ((FLOOR, (52, 46, 42)), (WALL, (96, 104, 132))):
135 noise = rng.integers(-10, 11, size=(TILE, TILE, 1))
136 atlas[:, tile_id * TILE : (tile_id + 1) * TILE, :3] = np.clip(np.array(base) + noise, 0, 255)
137 # Mortar on the wall tile: its top row and its left column.
138 atlas[0, TILE:, :3] = (60, 66, 88)
139 atlas[:, TILE, :3] = (60, 66, 88)
140 return atlas
141
142
143def make_player_texture() -> np.ndarray:
144 """A warm disc with a dark outline."""
145 img = np.zeros((PLAYER_SIZE, PLAYER_SIZE, 4), dtype=np.uint8)
146 yy, xx = np.mgrid[0:PLAYER_SIZE, 0:PLAYER_SIZE]
147 d = np.hypot(xx - (PLAYER_SIZE - 1) / 2, yy - (PLAYER_SIZE - 1) / 2)
148 img[d <= PLAYER_SIZE / 2 - 0.5] = (40, 28, 10, 255)
149 img[d <= PLAYER_SIZE / 2 - 2.0] = (255, 205, 70, 255)
150 return img
151
152
153class DungeonDemo(Node2D):
154 input_context = {
155 "move": [Axis2D.WASD, Axis2D.ARROWS, Controller.LEFT_STICK],
156 "new_seed": [Key.R, Pointer.PRIMARY],
157 "prev_seed": [Key.LEFT_BRACKET],
158 "next_seed": [Key.RIGHT_BRACKET],
159 }
160
161 def on_ready(self):
162 self.tree.content_scale_mode = ContentScaleMode.FIT
163 self.tree.content_scale_size = (GRID_W * TILE, GRID_H * TILE + HUD_HEIGHT)
164 atlas = make_atlas()
165 tile_set = TileSet.from_atlas_array(atlas, width=atlas.shape[1], height=atlas.shape[0], tile_size=(TILE, TILE))
166 self.tilemap = self.add_child(TileMap(name="Dungeon", tile_set=tile_set, cell_size=(TILE, TILE)))
167 self.player = self.add_child(
168 Sprite2D(name="Player", texture=make_player_texture(), width=PLAYER_SIZE, height=PLAYER_SIZE)
169 )
170 self.status = self.add_child(CanvasLayer()).add_child(
171 Label(anchor=Anchor.BOTTOM_WIDE, inset=(12, 4), size=(0, 22), font_size=15, autoshrink=True)
172 )
173 self.regenerate(seed=1)
174
175 def regenerate(self, seed: int) -> None:
176 """Build the dungeon for `seed`, draw it, and drop the player in the first room."""
177 self.dungeon = generate_dungeon(GRID_W, GRID_H, seed)
178 self.tilemap.get_layer(0).set_cells(self.dungeon.grid)
179 cx, cy = self.dungeon.rooms[0].centre
180 self.player.position = ((cx + 0.5) * TILE, (cy + 0.5) * TILE)
181 self.status.text = f"Seed {seed} rooms {len(self.dungeon.rooms)} " + HELP
182
183 @on_input("new_seed")
184 def _new_seed(self, _event):
185 self.regenerate(random.randrange(1_000_000))
186
187 @on_input("prev_seed")
188 def _prev_seed(self, _event):
189 self.regenerate(self.dungeon.seed - 1)
190
191 @on_input("next_seed")
192 def _next_seed(self, _event):
193 self.regenerate(self.dungeon.seed + 1)
194
195 def on_update(self, dt: float):
196 step = Input.value("move") * PLAYER_SPEED * dt
197 x, y = self.player.position
198 # One axis at a time, so a wall on one axis still lets the player slide along the other.
199 if step.x and can_stand(self.dungeon.grid, x + step.x, y):
200 x += step.x
201 if step.y and can_stand(self.dungeon.grid, x, y + step.y):
202 y += step.y
203 self.player.position = (x, y)
204
205
206# Example help overlay: not part of the technique shown.
207HELP = "R / click: new seed [ / ]: step seed WASD / arrows: move"
208
209
210if __name__ == "__main__":
211 App(title="Procedural Dungeon", width=960, height=540).run(DungeonDemo())