532 lines
18 KiB
Python
532 lines
18 KiB
Python
"""Dungeon Generation Demo - BSP + Corridors
|
|
|
|
Demonstrates BSP dungeon generation with:
|
|
1. Create BSP and split recursively
|
|
2. Visualize all BSP partitions (educational)
|
|
3. Extract leaf nodes as rooms
|
|
4. Shrink leaves to create room margins
|
|
5. Build adjacency graph (which rooms neighbor)
|
|
6. Connect adjacent rooms with corridors
|
|
7. Composite rooms + corridors
|
|
"""
|
|
|
|
import mcrfpy
|
|
from typing import List, Dict, Tuple, Set
|
|
from ..core.demo_base import ProcgenDemoBase, StepDef, LayerDef
|
|
from ..core.parameter import Parameter
|
|
|
|
|
|
class DungeonDemo(ProcgenDemoBase):
|
|
"""Interactive BSP dungeon generation demo."""
|
|
|
|
name = "Dungeon (BSP)"
|
|
description = "Binary Space Partitioning with adjacency-based corridors"
|
|
MAP_SIZE = (128, 96) # Smaller for better visibility of rooms
|
|
|
|
def define_steps(self) -> List[StepDef]:
|
|
"""Define the generation steps."""
|
|
return [
|
|
StepDef("Create BSP tree", self.step_create_bsp,
|
|
"Initialize BSP and split recursively"),
|
|
StepDef("Show all partitions", self.step_show_partitions,
|
|
"Visualize the full BSP tree structure"),
|
|
StepDef("Extract rooms", self.step_extract_rooms,
|
|
"Get leaf nodes as potential room spaces"),
|
|
StepDef("Shrink rooms", self.step_shrink_rooms,
|
|
"Add margins between rooms"),
|
|
StepDef("Build adjacency", self.step_build_adjacency,
|
|
"Find which rooms are neighbors"),
|
|
StepDef("Dig corridors", self.step_dig_corridors,
|
|
"Connect adjacent rooms with corridors"),
|
|
StepDef("Composite", self.step_composite,
|
|
"Combine rooms and corridors for final dungeon"),
|
|
]
|
|
|
|
def define_parameters(self) -> List[Parameter]:
|
|
"""Define configurable parameters."""
|
|
return [
|
|
Parameter(
|
|
name="seed",
|
|
display="Seed",
|
|
type="int",
|
|
default=42,
|
|
min_val=0,
|
|
max_val=99999,
|
|
step=1,
|
|
affects_step=0,
|
|
description="Random seed for BSP splits"
|
|
),
|
|
Parameter(
|
|
name="depth",
|
|
display="BSP Depth",
|
|
type="int",
|
|
default=4,
|
|
min_val=2,
|
|
max_val=6,
|
|
step=1,
|
|
affects_step=0,
|
|
description="BSP recursion depth"
|
|
),
|
|
Parameter(
|
|
name="min_room_w",
|
|
display="Min Room W",
|
|
type="int",
|
|
default=8,
|
|
min_val=4,
|
|
max_val=16,
|
|
step=2,
|
|
affects_step=0,
|
|
description="Minimum room width"
|
|
),
|
|
Parameter(
|
|
name="min_room_h",
|
|
display="Min Room H",
|
|
type="int",
|
|
default=6,
|
|
min_val=4,
|
|
max_val=12,
|
|
step=2,
|
|
affects_step=0,
|
|
description="Minimum room height"
|
|
),
|
|
Parameter(
|
|
name="shrink",
|
|
display="Room Shrink",
|
|
type="int",
|
|
default=2,
|
|
min_val=0,
|
|
max_val=4,
|
|
step=1,
|
|
affects_step=3,
|
|
description="Room inset from leaf bounds"
|
|
),
|
|
Parameter(
|
|
name="corridor_width",
|
|
display="Corridor W",
|
|
type="int",
|
|
default=2,
|
|
min_val=1,
|
|
max_val=3,
|
|
step=1,
|
|
affects_step=5,
|
|
description="Corridor thickness"
|
|
),
|
|
]
|
|
|
|
def define_layers(self) -> List[LayerDef]:
|
|
"""Define visualization layers."""
|
|
return [
|
|
LayerDef("final", "Final Dungeon", "color", z_index=-1, visible=True,
|
|
description="Combined rooms and corridors"),
|
|
LayerDef("bsp_tree", "BSP Tree", "color", z_index=0, visible=False,
|
|
description="All BSP partition boundaries"),
|
|
LayerDef("rooms", "Rooms Only", "color", z_index=1, visible=False,
|
|
description="Room areas without corridors"),
|
|
LayerDef("corridors", "Corridors", "color", z_index=2, visible=False,
|
|
description="Corridor paths only"),
|
|
LayerDef("adjacency", "Adjacency", "color", z_index=3, visible=False,
|
|
description="Lines between adjacent room centers"),
|
|
]
|
|
|
|
def __init__(self):
|
|
"""Initialize dungeon demo."""
|
|
super().__init__()
|
|
|
|
# BSP data
|
|
self.bsp = None
|
|
self.leaves = []
|
|
self.rooms = [] # List of (x, y, w, h) tuples
|
|
self.room_centers = [] # List of (cx, cy) tuples
|
|
self.adjacencies = [] # List of (room_idx_1, room_idx_2) pairs
|
|
|
|
# HeightMaps for visualization
|
|
self.hmap_rooms = self.create_heightmap("rooms", 0.0)
|
|
self.hmap_corridors = self.create_heightmap("corridors", 0.0)
|
|
|
|
def _clear_layers(self):
|
|
"""Clear all visualization layers."""
|
|
for layer in self.layers.values():
|
|
layer.fill(mcrfpy.Color(30, 28, 26))
|
|
|
|
def _draw_rect(self, layer, x, y, w, h, color, outline_only=False, alpha=None):
|
|
"""Draw a rectangle on a layer."""
|
|
map_w, map_h = self.MAP_SIZE
|
|
# Apply alpha if specified
|
|
if alpha is not None:
|
|
color = mcrfpy.Color(color.r, color.g, color.b, alpha)
|
|
if outline_only:
|
|
# Draw just the outline
|
|
for px in range(x, x + w):
|
|
if 0 <= px < map_w:
|
|
if 0 <= y < map_h:
|
|
layer.set((px, y), color)
|
|
if 0 <= y + h - 1 < map_h:
|
|
layer.set((px, y + h - 1), color)
|
|
for py in range(y, y + h):
|
|
if 0 <= py < map_h:
|
|
if 0 <= x < map_w:
|
|
layer.set((x, py), color)
|
|
if 0 <= x + w - 1 < map_w:
|
|
layer.set((x + w - 1, py), color)
|
|
else:
|
|
# Fill the rectangle
|
|
for py in range(y, y + h):
|
|
for px in range(x, x + w):
|
|
if 0 <= px < map_w and 0 <= py < map_h:
|
|
layer.set((px, py), color)
|
|
|
|
def _draw_line(self, layer, x0, y0, x1, y1, color, width=1, alpha=None):
|
|
"""Draw a line on a layer using Bresenham's algorithm."""
|
|
map_w, map_h = self.MAP_SIZE
|
|
# Apply alpha if specified
|
|
if alpha is not None:
|
|
color = mcrfpy.Color(color.r, color.g, color.b, alpha)
|
|
dx = abs(x1 - x0)
|
|
dy = abs(y1 - y0)
|
|
sx = 1 if x0 < x1 else -1
|
|
sy = 1 if y0 < y1 else -1
|
|
err = dx - dy
|
|
|
|
while True:
|
|
# Draw width around center point
|
|
for wo in range(-(width // 2), width // 2 + 1):
|
|
for ho in range(-(width // 2), width // 2 + 1):
|
|
px, py = x0 + wo, y0 + ho
|
|
if 0 <= px < map_w and 0 <= py < map_h:
|
|
layer.set((px, py), color)
|
|
|
|
if x0 == x1 and y0 == y1:
|
|
break
|
|
|
|
e2 = 2 * err
|
|
if e2 > -dy:
|
|
err -= dy
|
|
x0 += sx
|
|
if e2 < dx:
|
|
err += dx
|
|
y0 += sy
|
|
|
|
# === Step Implementations ===
|
|
|
|
def step_create_bsp(self):
|
|
"""Step 1: Create and split BSP tree."""
|
|
seed = self.get_param("seed")
|
|
depth = self.get_param("depth")
|
|
min_w = self.get_param("min_room_w")
|
|
min_h = self.get_param("min_room_h")
|
|
|
|
w, h = self.MAP_SIZE
|
|
|
|
# Create BSP covering the map (with margin)
|
|
margin = 2
|
|
self.bsp = mcrfpy.BSP(
|
|
pos=(margin, margin),
|
|
size=(w - margin * 2, h - margin * 2)
|
|
)
|
|
|
|
# Split recursively
|
|
self.bsp.split_recursive(
|
|
depth=depth,
|
|
min_size=(min_w, min_h),
|
|
seed=seed
|
|
)
|
|
|
|
# Clear and show initial state
|
|
self._clear_layers()
|
|
final = self.get_layer("final")
|
|
final.fill(mcrfpy.Color(30, 28, 26))
|
|
|
|
# Draw BSP root bounds
|
|
bsp_layer = self.get_layer("bsp_tree")
|
|
bsp_layer.fill(mcrfpy.Color(30, 28, 26))
|
|
x, y = self.bsp.pos
|
|
w, h = self.bsp.size
|
|
self._draw_rect(bsp_layer, x, y, w, h, mcrfpy.Color(80, 80, 100), outline_only=True)
|
|
|
|
def step_show_partitions(self):
|
|
"""Step 2: Visualize all BSP partitions."""
|
|
bsp_layer = self.get_layer("bsp_tree")
|
|
bsp_layer.fill(mcrfpy.Color(30, 28, 26, 128)) # alpha=128 for overlay
|
|
|
|
# Color palette for different depths (alpha=128 for overlay)
|
|
depth_colors = [
|
|
mcrfpy.Color(120, 60, 60, 128),
|
|
mcrfpy.Color(60, 120, 60, 128),
|
|
mcrfpy.Color(60, 60, 120, 128),
|
|
mcrfpy.Color(120, 120, 60, 128),
|
|
mcrfpy.Color(120, 60, 120, 128),
|
|
mcrfpy.Color(60, 120, 120, 128),
|
|
]
|
|
|
|
def draw_node(node, depth=0):
|
|
"""Recursively draw BSP nodes."""
|
|
x, y = node.pos
|
|
w, h = node.size
|
|
color = depth_colors[depth % len(depth_colors)]
|
|
|
|
# Draw outline
|
|
self._draw_rect(bsp_layer, x, y, w, h, color, outline_only=True)
|
|
|
|
# Draw children using left/right
|
|
if node.left:
|
|
draw_node(node.left, depth + 1)
|
|
if node.right:
|
|
draw_node(node.right, depth + 1)
|
|
|
|
# Start from root
|
|
root = self.bsp.root
|
|
if root:
|
|
draw_node(root)
|
|
|
|
# Also show on final layer
|
|
final = self.get_layer("final")
|
|
# Copy bsp_tree to final
|
|
w, h = self.MAP_SIZE
|
|
for y in range(h):
|
|
for x in range(w):
|
|
c = bsp_layer.at(x, y)
|
|
final.set((x, y), c)
|
|
|
|
def step_extract_rooms(self):
|
|
"""Step 3: Extract leaf nodes as rooms."""
|
|
# Get all leaves
|
|
self.leaves = list(self.bsp.leaves())
|
|
self.rooms = []
|
|
self.room_centers = []
|
|
|
|
rooms_layer = self.get_layer("rooms")
|
|
rooms_layer.fill(mcrfpy.Color(30, 28, 26, 128))
|
|
|
|
# Draw each leaf as a room (alpha=128 for overlay)
|
|
room_colors = [
|
|
mcrfpy.Color(100, 80, 60, 128),
|
|
mcrfpy.Color(80, 100, 60, 128),
|
|
mcrfpy.Color(60, 80, 100, 128),
|
|
mcrfpy.Color(100, 100, 60, 128),
|
|
]
|
|
|
|
for i, leaf in enumerate(self.leaves):
|
|
x, y = leaf.pos
|
|
w, h = leaf.size
|
|
self.rooms.append((x, y, w, h))
|
|
self.room_centers.append((x + w // 2, y + h // 2))
|
|
|
|
color = room_colors[i % len(room_colors)]
|
|
self._draw_rect(rooms_layer, x, y, w, h, color)
|
|
|
|
# Also show on final
|
|
final = self.get_layer("final")
|
|
map_w, map_h = self.MAP_SIZE
|
|
for y in range(map_h):
|
|
for x in range(map_w):
|
|
c = rooms_layer.at(x, y)
|
|
final.set((x, y), c)
|
|
|
|
print(f"Extracted {len(self.rooms)} rooms")
|
|
|
|
def step_shrink_rooms(self):
|
|
"""Step 4: Shrink rooms to add margins."""
|
|
shrink = self.get_param("shrink")
|
|
|
|
rooms_layer = self.get_layer("rooms")
|
|
rooms_layer.fill(mcrfpy.Color(30, 28, 26, 128))
|
|
|
|
# Shrink each room
|
|
shrunk_rooms = []
|
|
shrunk_centers = []
|
|
|
|
room_color = mcrfpy.Color(120, 100, 80, 128) # alpha=128 for overlay
|
|
|
|
for x, y, w, h in self.rooms:
|
|
# Apply shrink
|
|
nx = x + shrink
|
|
ny = y + shrink
|
|
nw = w - shrink * 2
|
|
nh = h - shrink * 2
|
|
|
|
# Ensure minimum size
|
|
if nw >= 3 and nh >= 3:
|
|
shrunk_rooms.append((nx, ny, nw, nh))
|
|
shrunk_centers.append((nx + nw // 2, ny + nh // 2))
|
|
self._draw_rect(rooms_layer, nx, ny, nw, nh, room_color)
|
|
|
|
# Store in heightmap for later
|
|
map_w, map_h = self.MAP_SIZE
|
|
for py in range(ny, ny + nh):
|
|
for px in range(nx, nx + nw):
|
|
if 0 <= px < map_w and 0 <= py < map_h:
|
|
self.hmap_rooms[px, py] = 1.0
|
|
|
|
self.rooms = shrunk_rooms
|
|
self.room_centers = shrunk_centers
|
|
|
|
# Update final
|
|
final = self.get_layer("final")
|
|
map_w, map_h = self.MAP_SIZE
|
|
for y in range(map_h):
|
|
for x in range(map_w):
|
|
c = rooms_layer.at(x, y)
|
|
final.set((x, y), c)
|
|
|
|
print(f"Shrunk to {len(self.rooms)} valid rooms")
|
|
|
|
def step_build_adjacency(self):
|
|
"""Step 5: Build adjacency graph between rooms."""
|
|
self.adjacencies = []
|
|
|
|
# Simple adjacency: rooms whose bounding boxes are close enough
|
|
# In a real implementation, use BSP adjacency
|
|
|
|
# For each pair of rooms, check if they share an edge
|
|
for i in range(len(self.rooms)):
|
|
for j in range(i + 1, len(self.rooms)):
|
|
r1 = self.rooms[i]
|
|
r2 = self.rooms[j]
|
|
|
|
# Check if rooms are adjacent (share edge or close)
|
|
if self._rooms_adjacent(r1, r2):
|
|
self.adjacencies.append((i, j))
|
|
|
|
# Visualize adjacency lines (alpha=128 for overlay)
|
|
adj_layer = self.get_layer("adjacency")
|
|
adj_layer.fill(mcrfpy.Color(30, 28, 26, 128))
|
|
|
|
line_color = mcrfpy.Color(200, 100, 100, 160) # semi-transparent overlay
|
|
for i, j in self.adjacencies:
|
|
c1 = self.room_centers[i]
|
|
c2 = self.room_centers[j]
|
|
self._draw_line(adj_layer, c1[0], c1[1], c2[0], c2[1], line_color, width=1)
|
|
|
|
# Show room centers as dots
|
|
center_color = mcrfpy.Color(255, 200, 0, 200) # more visible
|
|
for cx, cy in self.room_centers:
|
|
for dx in range(-1, 2):
|
|
for dy in range(-1, 2):
|
|
px, py = cx + dx, cy + dy
|
|
map_w, map_h = self.MAP_SIZE
|
|
if 0 <= px < map_w and 0 <= py < map_h:
|
|
adj_layer.set((px, py), center_color)
|
|
|
|
print(f"Found {len(self.adjacencies)} adjacencies")
|
|
|
|
def _rooms_adjacent(self, r1, r2) -> bool:
|
|
"""Check if two rooms are adjacent."""
|
|
x1, y1, w1, h1 = r1
|
|
x2, y2, w2, h2 = r2
|
|
|
|
# Horizontal adjacency (side by side)
|
|
h_gap = max(x1, x2) - min(x1 + w1, x2 + w2)
|
|
v_overlap = min(y1 + h1, y2 + h2) - max(y1, y2)
|
|
|
|
if h_gap <= 4 and v_overlap > 2:
|
|
return True
|
|
|
|
# Vertical adjacency (stacked)
|
|
v_gap = max(y1, y2) - min(y1 + h1, y2 + h2)
|
|
h_overlap = min(x1 + w1, x2 + w2) - max(x1, x2)
|
|
|
|
if v_gap <= 4 and h_overlap > 2:
|
|
return True
|
|
|
|
return False
|
|
|
|
def step_dig_corridors(self):
|
|
"""Step 6: Connect adjacent rooms with corridors."""
|
|
corridor_width = self.get_param("corridor_width")
|
|
|
|
corridors_layer = self.get_layer("corridors")
|
|
corridors_layer.fill(mcrfpy.Color(30, 28, 26, 128)) # alpha=128 for overlay
|
|
|
|
corridor_color = mcrfpy.Color(90, 85, 75, 128) # alpha=128 for overlay
|
|
|
|
for i, j in self.adjacencies:
|
|
c1 = self.room_centers[i]
|
|
c2 = self.room_centers[j]
|
|
|
|
# L-shaped corridor (horizontal then vertical)
|
|
mid_x = c1[0]
|
|
mid_y = c2[1]
|
|
|
|
# Horizontal segment
|
|
self._draw_line(corridors_layer, c1[0], c1[1], mid_x, mid_y,
|
|
corridor_color, width=corridor_width)
|
|
# Vertical segment
|
|
self._draw_line(corridors_layer, mid_x, mid_y, c2[0], c2[1],
|
|
corridor_color, width=corridor_width)
|
|
|
|
# Store in heightmap
|
|
map_w, map_h = self.MAP_SIZE
|
|
# Mark corridor cells
|
|
self._mark_line(c1[0], c1[1], mid_x, mid_y, corridor_width)
|
|
self._mark_line(mid_x, mid_y, c2[0], c2[1], corridor_width)
|
|
|
|
# Update final to show rooms + corridors
|
|
final = self.get_layer("final")
|
|
rooms_layer = self.get_layer("rooms")
|
|
map_w, map_h = self.MAP_SIZE
|
|
for y in range(map_h):
|
|
for x in range(map_w):
|
|
room_c = rooms_layer.at(x, y)
|
|
corr_c = corridors_layer.at(x, y)
|
|
# Prioritize rooms, then corridors, then background
|
|
if room_c.r > 50 or room_c.g > 50 or room_c.b > 50:
|
|
final.set((x, y), room_c)
|
|
elif corr_c.r > 50 or corr_c.g > 50 or corr_c.b > 50:
|
|
final.set((x, y), corr_c)
|
|
else:
|
|
final.set((x, y), mcrfpy.Color(30, 28, 26))
|
|
|
|
def _mark_line(self, x0, y0, x1, y1, width):
|
|
"""Mark corridor cells in heightmap."""
|
|
map_w, map_h = self.MAP_SIZE
|
|
dx = abs(x1 - x0)
|
|
dy = abs(y1 - y0)
|
|
sx = 1 if x0 < x1 else -1
|
|
sy = 1 if y0 < y1 else -1
|
|
err = dx - dy
|
|
|
|
while True:
|
|
for wo in range(-(width // 2), width // 2 + 1):
|
|
for ho in range(-(width // 2), width // 2 + 1):
|
|
px, py = x0 + wo, y0 + ho
|
|
if 0 <= px < map_w and 0 <= py < map_h:
|
|
self.hmap_corridors[px, py] = 1.0
|
|
|
|
if x0 == x1 and y0 == y1:
|
|
break
|
|
|
|
e2 = 2 * err
|
|
if e2 > -dy:
|
|
err -= dy
|
|
x0 += sx
|
|
if e2 < dx:
|
|
err += dx
|
|
y0 += sy
|
|
|
|
def step_composite(self):
|
|
"""Step 7: Create final composite dungeon."""
|
|
final = self.get_layer("final")
|
|
map_w, map_h = self.MAP_SIZE
|
|
|
|
wall_color = mcrfpy.Color(40, 38, 35)
|
|
floor_color = mcrfpy.Color(140, 130, 115)
|
|
|
|
for y in range(map_h):
|
|
for x in range(map_w):
|
|
is_room = self.hmap_rooms[x, y] > 0.5
|
|
is_corridor = self.hmap_corridors[x, y] > 0.5
|
|
|
|
if is_room or is_corridor:
|
|
final.set((x, y), floor_color)
|
|
else:
|
|
final.set((x, y), wall_color)
|
|
|
|
|
|
def main():
|
|
"""Run the dungeon demo standalone."""
|
|
demo = DungeonDemo()
|
|
demo.activate()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|