McRogueFace/docs/templates/complete/dungeon.py

298 lines
9.9 KiB
Python

"""
dungeon.py - Procedural Dungeon Generation for McRogueFace
Generates a roguelike dungeon with rooms connected by corridors.
Includes stairs placement for multi-level progression.
"""
import random
from dataclasses import dataclass
from typing import List, Tuple, Optional
from constants import (
DUNGEON_WIDTH, DUNGEON_HEIGHT,
ROOM_MIN_SIZE, ROOM_MAX_SIZE, MAX_ROOMS,
SPRITE_FLOOR, SPRITE_WALL, SPRITE_STAIRS_DOWN,
MAX_ENEMIES_PER_ROOM, MIN_ENEMIES_PER_ROOM,
ENEMY_SPAWN_WEIGHTS, DEFAULT_SPAWN_WEIGHTS
)
@dataclass
class Rect:
"""A rectangle representing a room in the dungeon."""
x: int
y: int
width: int
height: int
@property
def x2(self) -> int:
return self.x + self.width
@property
def y2(self) -> int:
return self.y + self.height
@property
def center(self) -> Tuple[int, int]:
"""Return the center coordinates of this room."""
center_x = (self.x + self.x2) // 2
center_y = (self.y + self.y2) // 2
return center_x, center_y
def intersects(self, other: 'Rect') -> bool:
"""Check if this room overlaps with another (with 1 tile buffer)."""
return (self.x <= other.x2 + 1 and self.x2 + 1 >= other.x and
self.y <= other.y2 + 1 and self.y2 + 1 >= other.y)
def inner(self) -> Tuple[int, int, int, int]:
"""Return the inner area of the room (excluding walls)."""
return self.x + 1, self.y + 1, self.width - 2, self.height - 2
class Tile:
"""Represents a single tile in the dungeon."""
def __init__(self, walkable: bool = False, transparent: bool = False,
sprite: int = SPRITE_WALL):
self.walkable = walkable
self.transparent = transparent
self.sprite = sprite
self.explored = False
self.visible = False
class Dungeon:
"""
The dungeon map with rooms, corridors, and tile data.
Attributes:
width: Width of the dungeon in tiles
height: Height of the dungeon in tiles
level: Current dungeon depth
tiles: 2D array of Tile objects
rooms: List of rooms (Rect objects)
player_start: Starting position for the player
stairs_pos: Position of the stairs down
"""
def __init__(self, width: int = DUNGEON_WIDTH, height: int = DUNGEON_HEIGHT,
level: int = 1):
self.width = width
self.height = height
self.level = level
self.tiles: List[List[Tile]] = []
self.rooms: List[Rect] = []
self.player_start: Tuple[int, int] = (0, 0)
self.stairs_pos: Tuple[int, int] = (0, 0)
# Initialize all tiles as walls
self._init_tiles()
def _init_tiles(self) -> None:
"""Fill the dungeon with wall tiles."""
self.tiles = [
[Tile(walkable=False, transparent=False, sprite=SPRITE_WALL)
for _ in range(self.height)]
for _ in range(self.width)
]
def in_bounds(self, x: int, y: int) -> bool:
"""Check if coordinates are within dungeon bounds."""
return 0 <= x < self.width and 0 <= y < self.height
def is_walkable(self, x: int, y: int) -> bool:
"""Check if a tile can be walked on."""
if not self.in_bounds(x, y):
return False
return self.tiles[x][y].walkable
def is_transparent(self, x: int, y: int) -> bool:
"""Check if a tile allows light to pass through."""
if not self.in_bounds(x, y):
return False
return self.tiles[x][y].transparent
def get_tile(self, x: int, y: int) -> Optional[Tile]:
"""Get the tile at the given position."""
if not self.in_bounds(x, y):
return None
return self.tiles[x][y]
def set_tile(self, x: int, y: int, walkable: bool, transparent: bool,
sprite: int) -> None:
"""Set properties of a tile."""
if self.in_bounds(x, y):
tile = self.tiles[x][y]
tile.walkable = walkable
tile.transparent = transparent
tile.sprite = sprite
def carve_room(self, room: Rect) -> None:
"""Carve out a room in the dungeon (make tiles walkable)."""
inner_x, inner_y, inner_w, inner_h = room.inner()
for x in range(inner_x, inner_x + inner_w):
for y in range(inner_y, inner_y + inner_h):
self.set_tile(x, y, walkable=True, transparent=True,
sprite=SPRITE_FLOOR)
def carve_tunnel_h(self, x1: int, x2: int, y: int) -> None:
"""Carve a horizontal tunnel."""
for x in range(min(x1, x2), max(x1, x2) + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite=SPRITE_FLOOR)
def carve_tunnel_v(self, y1: int, y2: int, x: int) -> None:
"""Carve a vertical tunnel."""
for y in range(min(y1, y2), max(y1, y2) + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite=SPRITE_FLOOR)
def connect_rooms(self, room1: Rect, room2: Rect) -> None:
"""Connect two rooms with an L-shaped corridor."""
x1, y1 = room1.center
x2, y2 = room2.center
# Randomly choose to go horizontal then vertical, or vice versa
if random.random() < 0.5:
self.carve_tunnel_h(x1, x2, y1)
self.carve_tunnel_v(y1, y2, x2)
else:
self.carve_tunnel_v(y1, y2, x1)
self.carve_tunnel_h(x1, x2, y2)
def place_stairs(self) -> None:
"""Place stairs in the last room."""
if self.rooms:
# Stairs go in the center of the last room
self.stairs_pos = self.rooms[-1].center
x, y = self.stairs_pos
self.set_tile(x, y, walkable=True, transparent=True,
sprite=SPRITE_STAIRS_DOWN)
def generate(self) -> None:
"""Generate the dungeon using BSP-style room placement."""
self._init_tiles()
self.rooms.clear()
for _ in range(MAX_ROOMS):
# Random room dimensions
w = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE)
h = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE)
# Random position (ensure room fits in dungeon)
x = random.randint(1, self.width - w - 1)
y = random.randint(1, self.height - h - 1)
new_room = Rect(x, y, w, h)
# Check for intersections with existing rooms
if any(new_room.intersects(other) for other in self.rooms):
continue
# Room is valid - carve it out
self.carve_room(new_room)
if self.rooms:
# Connect to previous room
self.connect_rooms(self.rooms[-1], new_room)
else:
# First room - player starts here
self.player_start = new_room.center
self.rooms.append(new_room)
# Place stairs in the last room
self.place_stairs()
def get_spawn_positions(self) -> List[Tuple[int, int]]:
"""
Get valid spawn positions for enemies.
Returns positions from all rooms except the first (player start).
"""
positions = []
for room in self.rooms[1:]: # Skip first room (player start)
inner_x, inner_y, inner_w, inner_h = room.inner()
for x in range(inner_x, inner_x + inner_w):
for y in range(inner_y, inner_y + inner_h):
# Don't spawn on stairs
if (x, y) != self.stairs_pos:
positions.append((x, y))
return positions
def get_enemy_spawns(self) -> List[Tuple[str, int, int]]:
"""
Determine which enemies to spawn and where.
Returns list of (enemy_type, x, y) tuples.
"""
spawns = []
# Get spawn weights for this level
weights = ENEMY_SPAWN_WEIGHTS.get(self.level, DEFAULT_SPAWN_WEIGHTS)
# Create weighted list for random selection
enemy_types = []
for enemy_type, weight in weights:
enemy_types.extend([enemy_type] * weight)
# Spawn enemies in each room (except the first)
for room in self.rooms[1:]:
num_enemies = random.randint(MIN_ENEMIES_PER_ROOM, MAX_ENEMIES_PER_ROOM)
# Scale up enemies slightly with dungeon level
num_enemies = min(num_enemies + (self.level - 1) // 2, MAX_ENEMIES_PER_ROOM + 2)
inner_x, inner_y, inner_w, inner_h = room.inner()
used_positions = set()
for _ in range(num_enemies):
# Find an unused position
attempts = 0
while attempts < 20:
x = random.randint(inner_x, inner_x + inner_w - 1)
y = random.randint(inner_y, inner_y + inner_h - 1)
if (x, y) not in used_positions and (x, y) != self.stairs_pos:
enemy_type = random.choice(enemy_types)
spawns.append((enemy_type, x, y))
used_positions.add((x, y))
break
attempts += 1
return spawns
def apply_to_grid(self, grid) -> None:
"""
Apply the dungeon data to a McRogueFace Grid object.
Args:
grid: A mcrfpy.Grid object to update
"""
for x in range(self.width):
for y in range(self.height):
tile = self.tiles[x][y]
point = grid.at(x, y)
point.tilesprite = tile.sprite
point.walkable = tile.walkable
point.transparent = tile.transparent
def generate_dungeon(level: int = 1) -> Dungeon:
"""
Convenience function to generate a new dungeon.
Args:
level: The dungeon depth (affects enemy spawns)
Returns:
A fully generated Dungeon object
"""
dungeon = Dungeon(level=level)
dungeon.generate()
return dungeon