Refactor map generator package

- Move room generators to map.generators.room
- Move corridor generators to map.generators.corridor

Generators have a generate() method that generates the things they place,
and an apply() method that applies their objects to a grid of tiles.
This commit is contained in:
Eryn Wells 2023-02-09 16:07:29 -08:00
parent 843aa2823f
commit 9a04692539
7 changed files with 391 additions and 262 deletions

View file

@ -18,17 +18,22 @@ from .geometry import Point, Rect, Size
from .interface import color
from .interface.percentage_bar import PercentageBar
from .map import Map
from .map.generator import RoomsAndCorridorsGenerator
from .map.generator.room import BSPRoomGenerator
from .map.generator.corridor import ElbowCorridorGenerator
from .messages import MessageLog
from .object import Actor, Entity, Hero, Monster
if TYPE_CHECKING:
from .events import EventHandler
@dataclass
class Configuration:
'''Configuration of the game engine'''
map_size: Size
class Engine:
'''The main game engine.
@ -56,23 +61,27 @@ class Engine:
self.did_successfully_process_actions_for_turn = False
self.rng = tcod.random.Random()
self.map = Map(configuration.map_size)
self.message_log = MessageLog()
map_size = configuration.map_size
map_generator = RoomsAndCorridorsGenerator(BSPRoomGenerator(size=map_size), ElbowCorridorGenerator())
self.map = Map(map_size, map_generator)
self.event_handler: 'EventHandler' = MainGameEventHandler(self)
self.current_mouse_point: Optional[Point] = None
self.hero = Hero(position=self.map.generator.rooms[0].center)
self.entities: MutableSet[Entity] = {self.hero}
for room in self.map.rooms:
self.entities: MutableSet[Entity] = set()
self.hero = Hero(position=self.map.random_walkable_position())
self.entities.add(self.hero)
while len(self.entities) < 25:
should_spawn_monster_chance = random.random()
if should_spawn_monster_chance < 0.4:
if should_spawn_monster_chance < 0.1:
continue
floor = list(room.walkable_tiles)
for _ in range(2):
while True:
random_start_position = random.choice(floor)
random_start_position = self.map.random_walkable_position()
if not any(ent.position == random_start_position for ent in self.entities):
break

View file

@ -13,6 +13,12 @@ class Point:
x: int = 0
y: int = 0
@property
def neighbors(self) -> Iterator['Point']:
'''Iterator over the neighboring points of `self` in all eight directions.'''
for direction in Direction.all():
yield self + direction
def is_adjacent_to(self, other: 'Point') -> bool:
'''Check if this point is adjacent to, but not overlapping the given point

View file

@ -1,40 +1,38 @@
# Eryn Wells <eryn@erynwells.me>
import random
from dataclasses import dataclass
from typing import Iterator, List, Optional
from typing import List, Optional
import numpy as np
import tcod
from .. import log
from ..geometry import Direction, Point, Rect, Size
from .tile import Empty, Floor, Shroud, Wall
from ..geometry import Point, Size
from .generator import MapGenerator
from .room import Room, RectangularRoom
from .tile import Empty, Shroud
class Map:
def __init__(self, size: Size, room_generator_class=RoomsAndCorridorsGenerator):
def __init__(self, size: Size, generator: MapGenerator):
self.size = size
self.generator = room_generator_class(size=size)
self.tiles = self.generator.generate()
self.generator = generator
self.tiles = np.full(tuple(size), fill_value=Empty, order='F')
self.generator.generate(self.tiles)
# Map tiles that are currently visible to the player
self.visible = np.full(tuple(self.size), fill_value=True, order='F')
# Map tiles that the player has explored
self.explored = np.full(tuple(self.size), fill_value=True, order='F')
@property
def rooms(self) -> List['Room']:
'''The list of rooms in the map'''
return self.generator.rooms
self.__walkable_points = None
def random_walkable_position(self) -> Point:
# TODO: Include hallways
random_room: RectangularRoom = random.choice(self.rooms)
floor: List[Point] = list(random_room.walkable_tiles)
random_position_in_room = random.choice(floor)
return random_position_in_room
'''Return a random walkable point on the map.'''
if not self.__walkable_points:
self.__walkable_points = [Point(x, y) for x, y in np.ndindex(
self.tiles.shape) if self.tiles[x, y]['walkable']]
return random.choice(self.__walkable_points)
def tile_is_in_bounds(self, point: Point) -> bool:
'''Return True if the given point is inside the bounds of the map'''
@ -54,240 +52,3 @@ class Map:
condlist=[self.visible, self.explored],
choicelist=[self.tiles['light'], self.tiles['dark']],
default=Shroud)
class MapGenerator:
def __init__(self, *, size: Size):
self.size = size
self.rooms: List['Room'] = []
def generate(self) -> np.ndarray:
'''
Generate a tile grid
Subclasses should implement this and fill in their specific map
generation algorithm.
Returns
-------
np.ndarray
A two-dimensional array of tiles. Dimensions should match the given size.
'''
raise NotImplementedError()
class RoomsAndCorridorsGenerator(MapGenerator):
'''Generate a rooms-and-corridors style map with BSP.'''
@dataclass
class Configuration:
minimum_room_size: Size
maximum_room_size: Size
DefaultConfiguration = Configuration(
minimum_room_size=Size(7, 7),
maximum_room_size=Size(20, 20),
)
def __init__(self, *, size: Size, config: Optional[Configuration] = None):
super().__init__(size=size)
self.configuration = config if config else RoomsAndCorridorsGenerator.DefaultConfiguration
self.rng: tcod.random.Random = tcod.random.Random()
self.rooms: List['RectangularRoom'] = []
self.tiles: Optional[np.ndarray] = None
def generate(self) -> np.ndarray:
if self.tiles:
return self.tiles
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
gap_for_walls = 2
bsp.split_recursive(
depth=4,
min_width=minimum_room_size.width + gap_for_walls,
min_height=minimum_room_size.height + gap_for_walls,
max_horizontal_ratio=1.1,
max_vertical_ratio=1.1
)
tiles = np.full(tuple(self.size), fill_value=Empty, order='F')
# Generate the rooms
rooms: List['RectangularRoom'] = []
room_attrname = f'{__class__.__name__}.room'
for node in bsp.post_order():
node_bounds = self.__rect_from_bsp_node(node)
if node.children:
log.MAP.debug(node_bounds)
left_room: RectangularRoom = getattr(node.children[0], room_attrname)
right_room: RectangularRoom = getattr(node.children[1], room_attrname)
left_room_bounds = left_room.bounds
right_room_bounds = right_room.bounds
log.MAP.debug(' left: %s, %s', node.children[0], left_room_bounds)
log.MAP.debug('right: %s, %s', node.children[1], right_room_bounds)
start_point = left_room_bounds.midpoint
end_point = right_room_bounds.midpoint
# Randomly choose whether to move horizontally then vertically or vice versa
if random.random() < 0.5:
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('|-> start: %s', left_room_bounds)
log.MAP.debug('`-> end: %s', right_room_bounds)
for x, y in tcod.los.bresenham(tuple(start_point), tuple(corner)).tolist():
tiles[x, y] = Floor
for x, y in tcod.los.bresenham(tuple(corner), tuple(end_point)).tolist():
tiles[x, y] = Floor
else:
log.MAP.debug('%s (room) %s', node_bounds, node)
# Generate a room size between minimum_room_size and maximum_room_size. The minimum value is
# straight-forward, but the maximum value needs to be clamped between minimum_room_size and the size of
# the node.
width_range = (
minimum_room_size.width,
min(maximum_room_size.width, max(
minimum_room_size.width, node.width - 2))
)
height_range = (
minimum_room_size.height,
min(maximum_room_size.height, max(
minimum_room_size.height, node.height - 2))
)
size = Size(self.rng.randint(*width_range),
self.rng.randint(*height_range))
origin = Point(node.x + self.rng.randint(1, max(1, node.width - size.width - 1)),
node.y + self.rng.randint(1, max(1, node.height - size.height - 1)))
bounds = Rect(origin, size)
log.MAP.debug('`-> %s', bounds)
room = RectangularRoom(bounds)
setattr(node, room_attrname, room)
rooms.append(room)
if not hasattr(node.parent, room_attrname):
setattr(node.parent, room_attrname, room)
elif random.random() < 0.5:
setattr(node.parent, room_attrname, room)
# Pass up a random child room so that parent nodes can connect subtrees to each other.
parent = node.parent
if parent:
node_room = getattr(node, room_attrname)
if not hasattr(node.parent, room_attrname):
setattr(node.parent, room_attrname, node_room)
elif random.random() < 0.5:
setattr(node.parent, room_attrname, node_room)
self.rooms = rooms
for room in rooms:
for wall_position in room.walls:
if tiles[wall_position.x, wall_position.y] != Floor:
tiles[wall_position.x, wall_position.y] = Wall
bounds = room.bounds
# The range of a numpy array slice is [a, b).
floor_rect = bounds.inset_rect(top=1, right=1, bottom=1, left=1)
tiles[floor_rect.min_x:floor_rect.max_x + 1,
floor_rect.min_y:floor_rect.max_y + 1] = Floor
for y in range(self.size.height):
for x in range(self.size.width):
pos = Point(x, y)
if tiles[x, y] != Floor:
continue
neighbors = (pos + direction for direction in Direction.all())
for neighbor in neighbors:
if tiles[neighbor.x, neighbor.y] != Empty:
continue
tiles[neighbor.x, neighbor.y] = Wall
self.tiles = tiles
return tiles
def __rect_from_bsp_node(self, node: tcod.bsp.BSP) -> Rect:
'''Create a Rect from the given BSP node object'''
return Rect(Point(node.x, node.y), Size(node.width, node.height))
class ElbowCorridorGenerator:
...
class NetHackCorridorGenerator:
'''A corridor generator that produces doors and corridors that look like Nethack's Dungeons of Doom levels.'''
...
class Room:
'''An abstract room. It can be any size or shape.'''
@property
def walkable_tiles(self) -> Iterator[Point]:
raise NotImplementedError()
class RectangularRoom(Room):
'''A rectangular room defined by a Rect.
Attributes
----------
bounds : Rect
A rectangle that defines the room. This rectangle includes the tiles used for the walls, so the floor is 1 tile
inset from the bounds.
'''
def __init__(self, bounds: Rect):
self.bounds = bounds
@property
def center(self) -> Point:
'''The center of the room, truncated according to integer math rules'''
return self.bounds.midpoint
@property
def walkable_tiles(self) -> Iterator[Point]:
floor_rect = self.bounds.inset_rect(top=1, right=1, bottom=1, left=1)
for y in range(floor_rect.min_y, floor_rect.max_y + 1):
for x in range(floor_rect.min_x, floor_rect.max_x + 1):
yield Point(x, y)
@property
def walls(self) -> Iterator[Point]:
bounds = self.bounds
min_y = bounds.min_y
max_y = bounds.max_y
min_x = bounds.min_x
max_x = bounds.max_x
for y in range(min_y, max_y + 1):
for x in range(min_x, max_x + 1):
if y == min_y or y == max_y or x == min_x or x == max_x:
yield Point(x, y)
def __repr__(self) -> str:
return f'{self.__class__.__name__}({self.bounds})'

View file

@ -0,0 +1,29 @@
import numpy as np
from ..tile import Empty
from .corridor import CorridorGenerator
from .room import RoomGenerator
class MapGenerator:
'''Abstract base class defining an interface for generating a map and applying it to a set of tiles.'''
def generate(self, tiles: np.ndarray):
raise NotImplementedError()
class RoomsAndCorridorsGenerator(MapGenerator):
'''
Generates a classic "rooms and corridors" style map with the given room and corridor generators.
'''
def __init__(self, room_generator: RoomGenerator, corridor_generator: CorridorGenerator):
self.room_generator = room_generator
self.corridor_generator = corridor_generator
def generate(self, tiles: np.ndarray):
self.room_generator.generate()
self.room_generator.apply(tiles)
self.corridor_generator.generate(self.room_generator.rooms)
self.corridor_generator.apply(tiles)

View file

@ -0,0 +1,92 @@
'''
Defines an abstract CorridorGenerator and several concrete subclasses. These classes generate corridors between rooms.
'''
import random
from itertools import pairwise
from typing import List
import tcod
import numpy as np
from ... import log
from ...geometry import Point
from ..room import Room
from ..tile import Empty, Floor, Wall
class CorridorGenerator:
'''
Corridor generators produce corridors between rooms.
'''
def generate(self, rooms: List[Room]) -> bool:
'''Generate corridors given a list of rooms.'''
raise NotImplementedError()
def apply(self, tiles: np.ndarray):
'''Apply corridors to a tile grid.'''
raise NotImplementedError()
class ElbowCorridorGenerator(CorridorGenerator):
'''
Generators corridors using a simple "elbow" algorithm:
```
For each pair of rooms:
1. Find the midpoint of the bounding rect of each room
2. Calculate an elbow point
3. Draw a path from the midpoint of the first room to the elbow point
4. Draw a path from the elbow point to the midpoint of the second room
```
'''
def __init__(self):
self.corridors: List[List[Point]] = []
def generate(self, rooms: List[Room]) -> bool:
for (left_room, right_room) in pairwise(rooms):
left_room_bounds = left_room.bounds
right_room_bounds = right_room.bounds
log.MAP.debug(' left: %s, %s', left_room, left_room_bounds)
log.MAP.debug('right: %s, %s', right_room, right_room_bounds)
start_point = left_room_bounds.midpoint
end_point = right_room_bounds.midpoint
# Randomly choose whether to move horizontally then vertically or vice versa
if random.random() < 0.5:
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('|-> start: %s', left_room_bounds)
log.MAP.debug('`-> end: %s', right_room_bounds)
corridor: List[Point] = []
for x, y in tcod.los.bresenham(tuple(start_point), tuple(corner)).tolist():
corridor.append(Point(x, y))
for x, y in tcod.los.bresenham(tuple(corner), tuple(end_point)).tolist():
corridor.append(Point(x, y))
self.corridors.append(corridor)
return True
def apply(self, tiles):
for corridor in self.corridors:
for pt in corridor:
tiles[pt.x, pt.y] = Floor
for neighbor in pt.neighbors:
if not (0 <= neighbor.x < tiles.shape[0] and 0 <= neighbor.y < tiles.shape[1]):
continue
if tiles[neighbor.x, neighbor.y] == Empty:
tiles[neighbor.x, neighbor.y] = Wall
class NetHackCorridorGenerator(CorridorGenerator):
'''A corridor generator that produces doors and corridors that look like Nethack's Dungeons of Doom levels.'''

View file

@ -0,0 +1,174 @@
import random
from dataclasses import dataclass
from typing import List, Optional
import numpy as np
import tcod
from ... import log
from ...geometry import Direction, Point, Rect, Size
from ..room import Room, RectangularRoom
from ..tile import Empty, Floor, Wall
class RoomGenerator:
'''Abstract room generator class.'''
def __init__(self, *, size: Size):
self.size = size
self.rooms: List[Room] = []
def generate(self) -> bool:
'''
Generate a list of rooms.
Subclasses should implement this and fill in their specific map
generation algorithm.
Returns
-------
np.ndarray
A two-dimensional array of tiles. Dimensions should match the given size.
'''
raise NotImplementedError()
def apply(self, tiles: np.ndarray):
'''
Apply the generated list of rooms to an array of tiles. Subclasses must implement this.
Arguments
---------
tiles: np.ndarray
The array of tiles to update.
'''
raise NotImplementedError()
class BSPRoomGenerator(RoomGenerator):
'''Generate a rooms-and-corridors style map with BSP.'''
@dataclass
class Configuration:
'''Configuration parameters for BSPRoomGenerator.'''
minimum_room_size: Size
maximum_room_size: Size
DefaultConfiguration = Configuration(
minimum_room_size=Size(7, 7),
maximum_room_size=Size(20, 20),
)
def __init__(self, *, size: Size, config: Optional[Configuration] = None):
super().__init__(size=size)
self.configuration = config if config else BSPRoomGenerator.DefaultConfiguration
self.rng: tcod.random.Random = tcod.random.Random()
self.rooms: List[RectangularRoom] = []
self.tiles: Optional[np.ndarray] = None
def generate(self) -> bool:
if self.rooms:
return True
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
gap_for_walls = 2
bsp.split_recursive(
depth=4,
min_width=minimum_room_size.width + gap_for_walls,
min_height=minimum_room_size.height + gap_for_walls,
max_horizontal_ratio=1.1,
max_vertical_ratio=1.1
)
# Generate the rooms
rooms: List['RectangularRoom'] = []
room_attrname = f'{__class__.__name__}.room'
for node in bsp.post_order():
node_bounds = self.__rect_from_bsp_node(node)
if node.children:
continue
log.MAP.debug('%s (room) %s', node_bounds, node)
# Generate a room size between minimum_room_size and maximum_room_size. The minimum value is
# straight-forward, but the maximum value needs to be clamped between minimum_room_size and the size of
# the node.
width_range = (
minimum_room_size.width,
min(maximum_room_size.width, max(
minimum_room_size.width, node.width - 2))
)
height_range = (
minimum_room_size.height,
min(maximum_room_size.height, max(
minimum_room_size.height, node.height - 2))
)
size = Size(self.rng.randint(*width_range),
self.rng.randint(*height_range))
origin = Point(node.x + self.rng.randint(1, max(1, node.width - size.width - 1)),
node.y + self.rng.randint(1, max(1, node.height - size.height - 1)))
bounds = Rect(origin, size)
log.MAP.debug('`-> %s', bounds)
room = RectangularRoom(bounds)
setattr(node, room_attrname, room)
rooms.append(room)
if not hasattr(node.parent, room_attrname):
setattr(node.parent, room_attrname, room)
elif random.random() < 0.5:
setattr(node.parent, room_attrname, room)
# Pass up a random child room so that parent nodes can connect subtrees to each other.
parent = node.parent
if parent:
node_room = getattr(node, room_attrname)
if not hasattr(node.parent, room_attrname):
setattr(node.parent, room_attrname, node_room)
elif random.random() < 0.5:
setattr(node.parent, room_attrname, node_room)
self.rooms = rooms
return True
def apply(self, tiles: np.ndarray):
for room in self.rooms:
for wall_position in room.walls:
if tiles[wall_position.x, wall_position.y] != Floor:
tiles[wall_position.x, wall_position.y] = Wall
bounds = room.bounds
# The range of a numpy array slice is [a, b).
floor_rect = bounds.inset_rect(top=1, right=1, bottom=1, left=1)
tiles[floor_rect.min_x:floor_rect.max_x + 1,
floor_rect.min_y:floor_rect.max_y + 1] = Floor
for y in range(self.size.height):
for x in range(self.size.width):
pos = Point(x, y)
if tiles[x, y] != Floor:
continue
neighbors = (pos + direction for direction in Direction.all())
for neighbor in neighbors:
if tiles[neighbor.x, neighbor.y] != Empty:
continue
tiles[neighbor.x, neighbor.y] = Wall
def __rect_from_bsp_node(self, node: tcod.bsp.BSP) -> Rect:
'''Create a Rect from the given BSP node object'''
return Rect(Point(node.x, node.y), Size(node.width, node.height))

58
erynrl/map/room.py Normal file
View file

@ -0,0 +1,58 @@
from typing import Iterator
from ..geometry import Point, Rect
class Room:
'''An abstract room. It can be any size or shape.'''
def __init__(self, bounds):
self.bounds = bounds
@property
def center(self) -> Point:
'''The center of the room, truncated according to integer math rules'''
return self.bounds.midpoint
@property
def walls(self) -> Iterator[Point]:
'''An iterator over all the wall tiles of this room.'''
raise NotImplementedError()
@property
def walkable_tiles(self) -> Iterator[Point]:
'''An iterator over all the walkable tiles in this room.'''
raise NotImplementedError()
class RectangularRoom(Room):
'''A rectangular room defined by a Rect.
Attributes
----------
bounds : Rect
A rectangle that defines the room. This rectangle includes the tiles used for the walls, so the floor is 1 tile
inset from the bounds.
'''
@property
def walkable_tiles(self) -> Iterator[Point]:
floor_rect = self.bounds.inset_rect(top=1, right=1, bottom=1, left=1)
for y in range(floor_rect.min_y, floor_rect.max_y + 1):
for x in range(floor_rect.min_x, floor_rect.max_x + 1):
yield Point(x, y)
@property
def walls(self) -> Iterator[Point]:
bounds = self.bounds
min_y = bounds.min_y
max_y = bounds.max_y
min_x = bounds.min_x
max_x = bounds.max_x
for y in range(min_y, max_y + 1):
for x in range(min_x, max_x + 1):
if y == min_y or y == max_y or x == min_x or x == max_x:
yield Point(x, y)
def __repr__(self) -> str:
return f'{self.__class__.__name__}({self.bounds})'