diff --git a/erynrl/engine.py b/erynrl/engine.py index 08a9261..897c4d5 100644 --- a/erynrl/engine.py +++ b/erynrl/engine.py @@ -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 diff --git a/erynrl/geometry.py b/erynrl/geometry.py index 872777d..7990f78 100644 --- a/erynrl/geometry.py +++ b/erynrl/geometry.py @@ -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 diff --git a/erynrl/map/__init__.py b/erynrl/map/__init__.py index a2f0b73..7267d59 100644 --- a/erynrl/map/__init__.py +++ b/erynrl/map/__init__.py @@ -1,40 +1,38 @@ # Eryn Wells 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})' diff --git a/erynrl/map/generator/__init__.py b/erynrl/map/generator/__init__.py new file mode 100644 index 0000000..ff99a64 --- /dev/null +++ b/erynrl/map/generator/__init__.py @@ -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) diff --git a/erynrl/map/generator/corridor.py b/erynrl/map/generator/corridor.py new file mode 100644 index 0000000..752c5e8 --- /dev/null +++ b/erynrl/map/generator/corridor.py @@ -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.''' diff --git a/erynrl/map/generator/room.py b/erynrl/map/generator/room.py new file mode 100644 index 0000000..2655c56 --- /dev/null +++ b/erynrl/map/generator/room.py @@ -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)) diff --git a/erynrl/map/room.py b/erynrl/map/room.py new file mode 100644 index 0000000..c4f2f66 --- /dev/null +++ b/erynrl/map/room.py @@ -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})'