298 lines
9.9 KiB
Python
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
|