# Eryn Wells import random from dataclasses import dataclass from typing import Iterator, 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 class Map: def __init__(self, size: Size): self.size = size self.generator = RoomsAndCorridorsGenerator(size=size) self.tiles = self.generator.generate() # Map tiles that are currently visible to the player self.visible = np.full(tuple(self.size), fill_value=False, order='F') # Map tiles that the player has explored self.explored = np.full(tuple(self.size), fill_value=False, order='F') @property def rooms(self) -> List['Room']: '''The list of rooms in the map''' return self.generator.rooms 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 def tile_is_in_bounds(self, point: Point) -> bool: '''Return True if the given point is inside the bounds of the map''' return 0 <= point.x < self.size.width and 0 <= point.y < self.size.height def tile_is_walkable(self, point: Point) -> bool: '''Return True if the tile at the given point is walkable''' return self.tiles[point.x, point.y]['walkable'] def print_to_console(self, console: tcod.Console) -> None: '''Render the map to the console.''' size = self.size # If a tile is in the visible array, draw it with the "light" color. If it's not, but it's in the explored # array, draw it with the "dark" color. Otherwise, draw it as Empty. console.tiles_rgb[0:size.width, 0:size.height] = np.select( 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(5, 5), maximum_room_size=Size(15, 15), ) 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) bsp.split_recursive( depth=4, # Add 2 to the minimum width and height to account for walls min_width=minimum_room_size.width + 2, min_height=minimum_room_size.height + 2, max_horizontal_ratio=3, max_vertical_ratio=3) 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 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) -> Rect: 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})'