Refactor how maps, rooms, and corridors are generated

- Rect and Room method objects no longer need to know the map size up front
- The Map object has lists of interesting map features (I don't like this)
- Room and corridor generators take the map itself as an argument to their
  generate and apply methods
- Create a Corridor object to hold a list of points
- Add a bunch of documentation here and there
This commit is contained in:
Eryn Wells 2023-03-11 00:06:47 -08:00
parent e1523cd9c0
commit b0d91c9c5d
6 changed files with 111 additions and 62 deletions

View file

@ -57,14 +57,11 @@ class Engine:
self.rng = tcod.random.Random()
self.message_log = MessageLog()
map_size = config.map_size
map_generator = RoomsAndCorridorsGenerator(
RoomGenerator(
size=map_size,
config=RoomGenerator.Configuration(
RoomGenerator.Configuration(
rect_method=BSPRectMethod(
size=map_size,
config=BSPRectMethod.Configuration(number_of_rooms=30)),
BSPRectMethod.Configuration(number_of_rooms=30)),
room_method=OrRoomMethod(
methods=[
(0.2, CellularAtomatonRoomMethod(CellularAtomataMapGenerator.Configuration())),

View file

@ -6,7 +6,7 @@ parts of a map.
'''
import random
from typing import Iterable
from typing import Iterable, List
import numpy as np
import tcod
@ -14,6 +14,7 @@ import tcod
from ..configuration import Configuration
from ..geometry import Point, Rect, Size
from .generator import MapGenerator
from .room import Corridor, Room
from .tile import Empty, Shroud
@ -29,9 +30,6 @@ class Map:
shape = map_size.numpy_shape
self.tiles = np.full(shape, fill_value=Empty, order='F')
self.up_stairs = generator.up_stairs
self.down_stairs = generator.down_stairs
self.highlighted = np.full(shape, fill_value=False, order='F')
# Map tiles that are currently visible to the player
@ -44,6 +42,12 @@ class Map:
generator.generate(self)
# Map Features
self.rooms: List[Room] = []
self.corridors: List[Corridor] = []
self.up_stairs = generator.up_stairs
self.down_stairs = generator.down_stairs
@property
def bounds(self) -> Rect:
'''The bounds of the map'''

View file

@ -1,8 +1,10 @@
# Eryn Wells <eryn@erynwells.me>
from typing import List, TYPE_CHECKING
'''
This module defines a bunch of mechanisms for generating maps.
'''
import numpy as np
from typing import List, TYPE_CHECKING
from .corridor import CorridorGenerator
from .room import RoomGenerator
@ -25,7 +27,6 @@ class MapGenerator:
'''The location of any routes to a lower floor of the dungeon.'''
raise NotImplementedError()
# pylint: disable=redefined-builtin
def generate(self, map: 'Map'):
'''Generate a map and place it in `tiles`'''
raise NotImplementedError()
@ -48,10 +49,8 @@ class RoomsAndCorridorsGenerator(MapGenerator):
def down_stairs(self) -> List[Point]:
return self.room_generator.down_stairs
# pylint: disable=redefined-builtin
def generate(self, map: 'Map'):
self.room_generator.generate()
self.room_generator.generate(map)
self.room_generator.apply(map)
self.corridor_generator.generate(self.room_generator.rooms)
self.corridor_generator.apply(map.tiles)
self.corridor_generator.generate(map)
self.corridor_generator.apply(map)

View file

@ -6,30 +6,35 @@ Defines an abstract CorridorGenerator and several concrete subclasses. These cla
import random
from itertools import pairwise
from typing import List
from typing import List, TYPE_CHECKING
import tcod
import numpy as np
from ... import log
from ...geometry import Point
from ..room import Room
from ..room import Corridor, Room
from ..tile import Empty, Floor, Wall
if TYPE_CHECKING:
from .. import Map
class CorridorGenerator:
'''
Corridor generators produce corridors between rooms.
'''
def generate(self, rooms: List[Room]) -> bool:
def generate(self, map: 'Map') -> bool:
'''Generate corridors given a list of rooms.'''
raise NotImplementedError()
def apply(self, tiles: np.ndarray):
def apply(self, map: 'Map'):
'''Apply corridors to a tile grid.'''
raise NotImplementedError()
def _sorted_rooms(self, rooms: List[Room]) -> List[Room]:
return sorted(rooms, key=lambda r: r.bounds.origin)
class ElbowCorridorGenerator(CorridorGenerator):
'''
@ -48,25 +53,23 @@ class ElbowCorridorGenerator(CorridorGenerator):
'''
def __init__(self):
self.corridors: List[List[Point]] = []
self.corridors: List[Corridor] = []
def generate(self, map: 'Map') -> bool:
rooms = map.rooms
def generate(self, rooms: List[Room]) -> bool:
if len(rooms) < 2:
return True
sorted_rooms = sorted(rooms, key=lambda r: r.bounds.origin)
sorted_rooms = self._sorted_rooms(rooms)
for (left_room, right_room) in pairwise(sorted_rooms):
for left_room, right_room in pairwise(sorted_rooms):
corridor = self._generate_corridor_between(left_room, right_room)
self.corridors.append(corridor)
for i in range(len(rooms) - 2):
corridor = self._generate_corridor_between(rooms[i], rooms[i + 2])
self.corridors.append(corridor)
return True
def _generate_corridor_between(self, left_room, right_room):
def _generate_corridor_between(self, left_room, right_room) -> Corridor:
left_room_bounds = left_room.bounds
right_room_bounds = right_room.bounds
@ -77,12 +80,18 @@ class ElbowCorridorGenerator(CorridorGenerator):
end_point = right_room_bounds.midpoint
# Randomly choose whether to move horizontally then vertically or vice versa
if random.random() < 0.5:
horizontal_first = random.random() < 0.5
if horizontal_first:
corner = Point(end_point.x, start_point.y)
else:
corner = Point(start_point.x, end_point.y)
log.MAP.debug('Digging a tunnel between %s and %s with corner %s', start_point, end_point, corner)
log.MAP.debug(
'Digging a tunnel between %s and %s with corner %s (%s)',
start_point,
end_point,
corner,
'horizontal' if horizontal_first else 'vertical')
log.MAP.debug('|-> start: %s', left_room_bounds)
log.MAP.debug('`-> end: %s', right_room_bounds)
@ -94,9 +103,13 @@ class ElbowCorridorGenerator(CorridorGenerator):
for x, y in tcod.los.bresenham(tuple(corner), tuple(end_point)).tolist():
corridor.append(Point(x, y))
return corridor
return Corridor(points=corridor)
def apply(self, map: 'Map'):
tiles = map.tiles
map.corridors = self.corridors
def apply(self, tiles):
for corridor in self.corridors:
for pt in corridor:
tiles[pt.x, pt.y] = Floor

View file

@ -23,24 +23,32 @@ class RoomGenerator:
@dataclass
class Configuration:
'''
Configuration of a RoomGenerator
### Attributes
rect_method : RectMethod
A RectMethod object to produce rectangles
room_method : RoomMethod
A RoomMethod object to produce rooms from rectangles
'''
rect_method: 'RectMethod'
room_method: 'RoomMethod'
def __init__(self, *, size: Size, config: Configuration):
self.size = size
def __init__(self, config: Configuration):
self.configuration = config
self.rooms: List[Room] = []
self.up_stairs: List[Point] = []
self.down_stairs: List[Point] = []
def generate(self):
def generate(self, map: 'Map'):
'''Generate rooms and stairs'''
rect_method = self.configuration.rect_method
room_method = self.configuration.room_method
for rect in rect_method.generate():
for rect in rect_method.generate(map):
room = room_method.room_in_rect(rect)
if not room:
break
@ -51,11 +59,10 @@ class RoomGenerator:
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)
self._apply_stairs(map)
def _apply(self, map: 'Map'):
'''
@ -68,6 +75,8 @@ class RoomGenerator:
'''
tiles = map.tiles
map.rooms = self.rooms
for room in self.rooms:
for pt in room.floor_points:
tiles[pt.numpy_index] = Floor
@ -93,7 +102,12 @@ class RoomGenerator:
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):
def _apply_stairs(self, map: 'Map'):
tiles = map.tiles
map.up_stairs = self.up_stairs
map.down_stairs = self.down_stairs
for pt in self.up_stairs:
tiles[pt.numpy_index] = StairsUp
for pt in self.down_stairs:
@ -103,10 +117,7 @@ class RoomGenerator:
class RectMethod:
'''An abstract class defining a method for generating rooms.'''
def __init__(self, *, size: Size):
self.size = size
def generate(self) -> Iterator[Rect]:
def generate(self, map: 'Map') -> Iterator[Rect]:
'''Generate rects to place rooms in until there are no more.'''
raise NotImplementedError()
@ -132,13 +143,14 @@ class OneBigRoomRectMethod(RectMethod):
width_percentage: float = 0.5
height_percentage: float = 0.5
def __init__(self, *, size: Size, config: Optional[Configuration] = None):
super().__init__(size=size)
def __init__(self, config: Optional[Configuration] = None):
super().__init__()
self.configuration = config or self.__class__.Configuration()
def generate(self) -> Iterator[Rect]:
width = self.size.width
height = self.size.height
def generate(self, map: 'Map') -> Iterator[Rect]:
map_size = map.bounds.size
width = map_size.width
height = map_size.height
size = Size(math.floor(width * self.configuration.width_percentage),
math.floor(height * self.configuration.height_percentage))
@ -156,23 +168,24 @@ class RandomRectMethod(RectMethod):
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)
def __init__(self, config: Optional[Configuration] = None):
self.configuration = config or self.__class__.Configuration()
self._rects: List[Rect] = []
def generate(self) -> Iterator[Rect]:
def generate(self, map: 'Map') -> 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)
map_size = map.size
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))
origin = Point(random.randint(0, map_size.width - size.width),
random.randint(0, map_size.height - size.height))
candidate_rect = Rect(origin, size)
overlaps_any_existing_room = any(candidate_rect.intersects(r) for r in self._rects)
@ -186,6 +199,10 @@ class RandomRectMethod(RectMethod):
class BSPRectMethod(RectMethod):
'''
Generate rectangles with Binary Space Partitioning.
'''
@dataclass
class Configuration:
'''
@ -219,18 +236,19 @@ class BSPRectMethod(RectMethod):
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)
def __init__(self, config: Optional[Configuration] = None):
self.configuration = config or self.__class__.Configuration()
def generate(self) -> Iterator[Rect]:
def generate(self, map: 'Map') -> Iterator[Rect]:
nodes_with_rooms = set()
minimum_room_size = self.configuration.minimum_room_size
maximum_room_size = self.configuration.maximum_room_size
map_size = map.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)
bsp = tcod.bsp.BSP(x=0, y=0, width=map_size.width, height=map_size.height)
# Add 2 to the minimum width and height to account for walls
bsp.split_recursive(

View file

@ -4,7 +4,7 @@
Implements an abstract Room class, and subclasses that implement it. Rooms are basic components of maps.
'''
from typing import Iterable
from typing import Iterable, Iterator, List, Optional
import numpy as np
@ -118,3 +118,21 @@ class FreeformRoom(Room):
def __str__(self):
return '\n'.join(''.join(chr(i['light']['ch']) for i in row) for row in self.tiles)
class Corridor:
'''
A corridor is a list of points connecting two endpoints
'''
def __init__(self, points: Optional[List[Point]] = None):
self.points: List[Point] = points or []
@property
def length(self) -> int:
'''The length of this corridor'''
return len(self.points)
def __iter__(self) -> Iterator[Point]:
for pt in self.points:
yield pt