going-rogue/erynrl/map/generator/room.py

362 lines
12 KiB
Python

# Eryn Wells <eryn@erynwells.me>
import math
import random
from dataclasses import dataclass
from typing import Iterable, Iterator, List, Optional, Tuple, TYPE_CHECKING
import numpy as np
import tcod
from ... import log
from ...geometry import Point, Rect, Size
from ..room import FreeformRoom, RectangularRoom, Room
from ..tile import Empty, Floor, StairsDown, StairsUp, Wall, tile_datatype
from .cellular_atomata import CellularAtomataMapGenerator
if TYPE_CHECKING:
from .. import Map
class RoomGenerator:
'''Abstract room generator class.'''
@dataclass
class Configuration:
rect_method: 'RectMethod'
room_method: 'RoomMethod'
def __init__(self, *, size: Size, config: Configuration):
self.size = size
self.configuration = config
self.rooms: List[Room] = []
self.up_stairs: List[Point] = []
self.down_stairs: List[Point] = []
def generate(self):
'''Generate rooms and stairs'''
rect_method = self.configuration.rect_method
room_method = self.configuration.room_method
for rect in rect_method.generate():
room = room_method.room_in_rect(rect)
if not room:
break
self.rooms.append(room)
if len(self.rooms) == 0:
return
self._generate_stairs()
# pylint: disable=redefined-builtin
def apply(self, map: 'Map'):
'''Apply the generated rooms to a tile array'''
self._apply(map)
self._apply_stairs(map.tiles)
def _apply(self, map: 'Map'):
'''
Apply the generated list of rooms to an array of tiles. Subclasses must implement this.
Arguments
---------
map: Map
The game map to apply the generated room to
'''
tiles = map.tiles
for room in self.rooms:
for pt in room.floor_points:
tiles[pt.numpy_index] = Floor
for room in self.rooms:
for pt in room.wall_points:
idx = pt.numpy_index
if tiles[idx] != Empty:
continue
tiles[idx] = Wall
def _generate_stairs(self):
up_stair_room = random.choice(self.rooms)
down_stair_room = None
if len(self.rooms) >= 2:
while down_stair_room is None or down_stair_room == up_stair_room:
down_stair_room = random.choice(self.rooms)
else:
down_stair_room = up_stair_room
self.up_stairs.append(random.choice(list(up_stair_room.walkable_tiles)))
self.down_stairs.append(random.choice(list(down_stair_room.walkable_tiles)))
def _apply_stairs(self, tiles):
for pt in self.up_stairs:
tiles[pt.numpy_index] = StairsUp
for pt in self.down_stairs:
tiles[pt.numpy_index] = StairsDown
class RectMethod:
'''An abstract class defining a method for generating rooms.'''
def __init__(self, *, size: Size):
self.size = size
def generate(self) -> Iterator[Rect]:
'''Generate rects to place rooms in until there are no more.'''
raise NotImplementedError()
class OneBigRoomRectMethod(RectMethod):
'''
A room generator method that yields one large rectangle centered in the
bounds defined by the zero origin and `self.size`.
'''
@dataclass
class Configuration:
'''
Configuration for a OneBigRoom room generator method.
### Attributes
width_percentage : float
The percentage of overall width to make the room
height_percentage : float
The percentage of overall height to make the room
'''
width_percentage: float = 0.5
height_percentage: float = 0.5
def __init__(self, *, size: Size, config: Optional[Configuration] = None):
super().__init__(size=size)
self.configuration = config or self.__class__.Configuration()
def generate(self) -> Iterator[Rect]:
width = self.size.width
height = self.size.height
size = Size(math.floor(width * self.configuration.width_percentage),
math.floor(height * self.configuration.height_percentage))
origin = Point((width - size.width) // 2, (height - size.height) // 2)
yield Rect(origin, size)
class RandomRectMethod(RectMethod):
NUMBER_OF_ATTEMPTS_PER_RECT = 30
@dataclass
class Configuration:
number_of_rooms: int = 30
minimum_room_size: Size = Size(7, 7)
maximum_room_size: Size = Size(20, 20)
def __init__(self, *, size: Size, config: Optional[Configuration] = None):
super().__init__(size=size)
self.configuration = config or self.__class__.Configuration()
self._rects: List[Rect] = []
def generate(self) -> Iterator[Rect]:
minimum_room_size = self.configuration.minimum_room_size
maximum_room_size = self.configuration.maximum_room_size
width_range = (minimum_room_size.width, maximum_room_size.width)
height_range = (minimum_room_size.height, maximum_room_size.height)
while len(self._rects) < self.configuration.number_of_rooms:
for _ in range(self.__class__.NUMBER_OF_ATTEMPTS_PER_RECT):
size = Size(random.randint(*width_range), random.randint(*height_range))
origin = Point(random.randint(0, self.size.width - size.width),
random.randint(0, self.size.height - size.height))
candidate_rect = Rect(origin, size)
overlaps_any_existing_room = any(candidate_rect.intersects(r) for r in self._rects)
if not overlaps_any_existing_room:
break
else:
return
self._rects.append(candidate_rect)
yield candidate_rect
class BSPRectMethod(RectMethod):
@dataclass
class Configuration:
'''
Configuration for the binary space partitioning (BSP) Rect method.
### Attributes
number_of_rooms : int
The maximum number of rooms to produce
maximum_room_size : Size
The maximum size of any room
minimum_room_size : Size
The minimum size of any room
room_size_ratio : Tuple[float, float]
A pair of floats indicating the maximum proportion the sides of a
BSP node can have to each other.
The first value is the horizontal ratio. BSP nodes will never have a
horizontal size (width) bigger than `room_size_ratio[0]` times the
vertical size.
The second value is the vertical ratio. BSP nodes will never have a
vertical size (height) larger than `room_size_ratio[1]` times the
horizontal size.
The closer these values are to 1.0, the more square the BSP nodes
will be.
'''
number_of_rooms: int = 30
minimum_room_size: Size = Size(7, 7)
maximum_room_size: Size = Size(20, 20)
room_size_ratio: Tuple[float, float] = (1.1, 1.1)
def __init__(self, *, size: Size, config: Optional[Configuration] = None):
super().__init__(size=size)
self.configuration = config or self.__class__.Configuration()
def generate(self) -> Iterator[Rect]:
nodes_with_rooms = set()
minimum_room_size = self.configuration.minimum_room_size
maximum_room_size = self.configuration.maximum_room_size
# Recursively divide the map into squares of various sizes to place rooms in.
bsp = tcod.bsp.BSP(x=0, y=0, width=self.size.width, height=self.size.height)
# Add 2 to the minimum width and height to account for walls
bsp.split_recursive(
depth=6,
min_width=minimum_room_size.width,
min_height=minimum_room_size.height,
max_horizontal_ratio=self.configuration.room_size_ratio[0],
max_vertical_ratio=self.configuration.room_size_ratio[1])
log.MAP_BSP.info('Generating room rects via BSP')
# Visit all nodes in a level before visiting any of their children
for bsp_node in bsp.level_order():
node_width = bsp_node.w
node_height = bsp_node.h
if node_width > maximum_room_size.width or node_height > maximum_room_size.height:
log.MAP_BSP.debug('Node with size (%s, %s) exceeds maximum size %s',
node_width, node_height, maximum_room_size)
continue
if len(nodes_with_rooms) >= self.configuration.number_of_rooms:
# Made as many rooms as we're allowed. We're done.
log.MAP_BSP.debug("Generated enough rooms (more than %d); we're done",
self.configuration.number_of_rooms)
return
if any(node in nodes_with_rooms for node in self.__all_parents_of_node(bsp_node)):
# Already made a room for one of this node's parents
log.MAP_BSP.debug('Already made a room for parent of %s', bsp_node)
continue
try:
probability_of_room = max(
1.0 / (node_width - minimum_room_size.width),
1.0 / (node_height - minimum_room_size.height))
except ZeroDivisionError:
probability_of_room = 1.0
log.MAP_BSP.info('Probability of generating room for %s: %f', bsp_node, probability_of_room)
if random.random() <= probability_of_room:
log.MAP_BSP.info('Yielding room for node %s', bsp_node)
nodes_with_rooms.add(bsp_node)
yield self.__rect_from_bsp_node(bsp_node)
log.MAP_BSP.info('Finished BSP room rect generation, yielded %d rooms', len(nodes_with_rooms))
def __rect_from_bsp_node(self, bsp_node: tcod.bsp.BSP) -> Rect:
return Rect.from_raw_values(bsp_node.x, bsp_node.y, bsp_node.w, bsp_node.h)
def __all_parents_of_node(self, node: tcod.bsp.BSP | None) -> Iterable[tcod.bsp.BSP]:
while node:
yield node
node = node.parent
class RoomMethod:
'''An abstract class defining a method for generating rooms.'''
def room_in_rect(self, rect: Rect) -> Optional[Room]:
'''Create a Room inside the given Rect.'''
raise NotImplementedError()
class RectangularRoomMethod(RoomMethod):
def room_in_rect(self, rect: Rect) -> Optional[Room]:
return RectangularRoom(rect)
class CellularAtomatonRoomMethod(RoomMethod):
def __init__(self, cellular_atomaton_config: CellularAtomataMapGenerator.Configuration):
self.cellular_atomaton_configuration = cellular_atomaton_config
def room_in_rect(self, rect: Rect) -> Optional[Room]:
# The cellular atomaton doesn't generate any walls, just floors and
# emptiness. Inset it by 1 all the way around so that we can draw walls
# around it.
atomaton_rect = rect.inset_rect(1, 1, 1, 1)
room_generator = CellularAtomataMapGenerator(atomaton_rect, self.cellular_atomaton_configuration)
room_generator.generate()
# Create a new tile array and copy the result of the atomaton into it,
# then draw walls everywhere that neighbors a floor tile.
width = rect.width
height = rect.height
room_tiles = np.full((height, width), fill_value=Empty, dtype=tile_datatype, order='C')
room_tiles[1:height - 1, 1:width - 1] = room_generator.tiles
for y, x in np.ndindex(room_tiles.shape):
if room_tiles[y, x] == Floor:
continue
for neighbor in Point(x, y).neighbors:
try:
if room_tiles[neighbor.y, neighbor.x] != Floor:
continue
room_tiles[y, x] = Wall
break
except IndexError:
pass
return FreeformRoom(rect, room_tiles)
class OrRoomMethod(RoomMethod):
'''
A room generator method that picks between several RoomMethods at random
based on a set of probabilities.
'''
def __init__(self, methods: Iterable[Tuple[float, RoomMethod]]):
assert sum(m[0] for m in methods) == 1.0
self.methods = methods
def room_in_rect(self, rect: Rect) -> Optional[Room]:
factor = random.random()
threshold = 0
for method in self.methods:
threshold += method[0]
if factor <= threshold:
return method[1].room_in_rect(rect)
return None