Merge branch 'diggin-tunnels'

This commit is contained in:
Eryn Wells 2022-05-03 19:05:57 -07:00
commit 0b577ad5ea
7 changed files with 224 additions and 35 deletions

53
bsp_visualizer.py Normal file
View file

@ -0,0 +1,53 @@
#!/usr/bin/env python3
# Eryn Wells <eryn@erynwells.me>
import argparse
import tcod
def parse_args(argv, *a, **kw):
parser = argparse.ArgumentParser(*a, **kw)
parser.add_argument('width', type=int)
parser.add_argument('height', type=int)
args = parser.parse_args(argv)
return args
def main(argv):
args = parse_args(argv[1:], prog=argv[0])
bsp = tcod.bsp.BSP(0, 0, args.width, args.height)
bsp.split_recursive(
depth=3,
min_width=5, min_height=5,
max_vertical_ratio=1.5, max_horizontal_ratio=1.5
)
node_names = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
current_node_name_index = 0
print('digraph {')
for node in bsp.post_order():
try:
node_name = getattr(node, 'viz_name')
except AttributeError:
node_name = node_names[current_node_name_index]
setattr(node, 'viz_name', node_name)
bounds = (node.x, node.y, node.width, node.height)
print(f' {node_name} [label=\"{current_node_name_index}: {bounds}\"]')
current_node_name_index += 1
if node.children:
node_name = getattr(node, 'viz_name')
left_child_name = getattr(node.children[0], 'viz_name')
right_child_name = getattr(node.children[1], 'viz_name')
print(f' {node_name} -> {left_child_name}')
print(f' {node_name} -> {right_child_name}')
print('}')
if __name__ == '__main__':
import sys
result = main(sys.argv)
sys.exit(0 if not result else result)

View file

@ -2,7 +2,12 @@
# Eryn Wells <eryn@erynwells.me> # Eryn Wells <eryn@erynwells.me>
import logging import logging
from .geometry import Vector from .geometry import Direction
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .engine import Engine
from .object import Entity
LOG = logging.getLogger('events') LOG = logging.getLogger('events')
@ -26,16 +31,6 @@ class RegenerateRoomsAction(Action):
... ...
class MovePlayerAction(Action): class MovePlayerAction(Action):
class Direction:
North = Vector(0, -1)
NorthEast = Vector(1, -1)
East = Vector(1, 0)
SouthEast = Vector(1, 1)
South = Vector(0, 1)
SouthWest = Vector(-1, 1)
West = Vector(-1, 0)
NorthWest = Vector(-1, -1)
def __init__(self, direction: Direction): def __init__(self, direction: Direction):
self.direction = direction self.direction = direction

View file

@ -33,8 +33,8 @@ class Engine:
self.player = Entity('@', position=player_start_position, fg=tcod.white) self.player = Entity('@', position=player_start_position, fg=tcod.white)
self.entities: AbstractSet[Entity] = {self.player} self.entities: AbstractSet[Entity] = {self.player}
for _ in range(self.rng.randint(1, 15)): for _ in range(self.rng.randint(5, 15)):
position = Point(self.rng.randint(0, map_size.width), self.rng.randint(0, map_size.height)) position = self.map.random_walkable_position()
self.entities.add(Entity('@', position=position, fg=tcod.yellow)) self.entities.add(Entity('@', position=position, fg=tcod.yellow))
def handle_event(self, event: tcod.event.Event): def handle_event(self, event: tcod.event.Event):

View file

@ -3,6 +3,7 @@
import tcod import tcod
from .actions import Action, ExitAction, MovePlayerAction, RegenerateRoomsAction from .actions import Action, ExitAction, MovePlayerAction, RegenerateRoomsAction
from .geometry import Direction
from typing import Optional from typing import Optional
class EventHandler(tcod.event.EventDispatch[Action]): class EventHandler(tcod.event.EventDispatch[Action]):
@ -15,13 +16,13 @@ class EventHandler(tcod.event.EventDispatch[Action]):
sym = event.sym sym = event.sym
if sym == tcod.event.KeySym.h: if sym == tcod.event.KeySym.h:
action = MovePlayerAction(MovePlayerAction.Direction.West) action = MovePlayerAction(Direction.West)
elif sym == tcod.event.KeySym.j: elif sym == tcod.event.KeySym.j:
action = MovePlayerAction(MovePlayerAction.Direction.South) action = MovePlayerAction(Direction.South)
elif sym == tcod.event.KeySym.k: elif sym == tcod.event.KeySym.k:
action = MovePlayerAction(MovePlayerAction.Direction.North) action = MovePlayerAction(Direction.North)
elif sym == tcod.event.KeySym.l: elif sym == tcod.event.KeySym.l:
action = MovePlayerAction(MovePlayerAction.Direction.East) action = MovePlayerAction(Direction.East)
elif sym == tcod.event.KeySym.SPACE: elif sym == tcod.event.KeySym.SPACE:
action = RegenerateRoomsAction() action = RegenerateRoomsAction()

View file

@ -18,6 +18,10 @@ class Point:
raise TypeError('Only Vector can be added to a Point') raise TypeError('Only Vector can be added to a Point')
return Point(self.x + other.dx, self.y + other.dy) return Point(self.x + other.dx, self.y + other.dy)
def __iter__(self):
yield self.x
yield self.y
def __str__(self): def __str__(self):
return f'(x:{self.x}, y:{self.y})' return f'(x:{self.x}, y:{self.y})'
@ -26,14 +30,32 @@ class Vector:
dx: int = 0 dx: int = 0
dy: int = 0 dy: int = 0
def __iter__(self):
yield self.dx
yield self.dy
def __str__(self): def __str__(self):
return f'(δx:{self.x}, δy:{self.y})' return f'(δx:{self.x}, δy:{self.y})'
class Direction:
North = Vector(0, -1)
NorthEast = Vector(1, -1)
East = Vector(1, 0)
SouthEast = Vector(1, 1)
South = Vector(0, 1)
SouthWest = Vector(-1, 1)
West = Vector(-1, 0)
NorthWest = Vector(-1, -1)
@dataclass(frozen=True) @dataclass(frozen=True)
class Size: class Size:
width: int = 0 width: int = 0
height: int = 0 height: int = 0
def __iter__(self):
yield self.width
yield self.height
def __str__(self): def __str__(self):
return f'(w:{self.width}, h:{self.height})' return f'(w:{self.width}, h:{self.height})'
@ -76,6 +98,18 @@ class Rect:
def midpoint(self) -> Point: def midpoint(self) -> Point:
return Point(self.mid_x, self.mid_y) return Point(self.mid_x, self.mid_y)
def inset_rect(self, top: int = 0, right: int = 0, bottom: int = 0, left: int = 0) -> 'Rect':
'''
Return a new Rect inset from this rect by the specified values. Arguments are listed in clockwise order around
the permeter. This method doesn't do any validation or transformation of the returned Rect to make sure it's
valid.
'''
return Rect(Point(self.origin.x + left, self.origin.y + top),
Size(self.size.width - right - left, self.size.height - top - bottom))
def __iter__(self):
yield tuple(self.origin)
yield tuple(self.size)
def __str__(self): def __str__(self):
return f'[{self.origin}, {self.size}]' return f'[{self.origin}, {self.size}]'

View file

@ -3,10 +3,11 @@
import logging import logging
import numpy as np import numpy as np
import random
import tcod import tcod
from .geometry import Point, Rect, Size from .geometry import Direction, Point, Rect, Size
from .tile import Floor, Wall from .tile import Empty, Floor, Wall
from typing import List, Optional from typing import Iterator, List, Optional
LOG = logging.getLogger('map') LOG = logging.getLogger('map')
@ -17,6 +18,14 @@ class Map:
self.generator = RoomsAndCorridorsGenerator(size=size) self.generator = RoomsAndCorridorsGenerator(size=size)
self.tiles = self.generator.generate() self.tiles = self.generator.generate()
def random_walkable_position(self) -> Point:
# TODO: Include hallways
random_room: RectangularRoom = random.choice(self.generator.rooms)
floor = random_room.floor_bounds
random_position_in_room = Point(random.randint(floor.min_x, floor.max_x),
random.randint(floor.min_y, floor.max_y))
return random_position_in_room
def tile_is_in_bounds(self, point: Point) -> bool: def tile_is_in_bounds(self, point: Point) -> bool:
return 0 <= point.x < self.size.width and 0 <= point.y < self.size.height return 0 <= point.x < self.size.width and 0 <= point.y < self.size.height
@ -44,11 +53,13 @@ class RoomsAndCorridorsGenerator(MapGenerator):
'''Generate a rooms-and-corridors style map with BSP.''' '''Generate a rooms-and-corridors style map with BSP.'''
class Configuration: class Configuration:
def __init__(self, min_room_size: Size): def __init__(self, min_room_size: Size, max_room_size: Size):
self.minimum_room_size = min_room_size self.minimum_room_size = min_room_size
self.maximum_room_size = max_room_size
DefaultConfiguration = Configuration( DefaultConfiguration = Configuration(
min_room_size=Size(8, 8) min_room_size=Size(5, 5),
max_room_size=Size(15, 15),
) )
def __init__(self, *, size: Size, config: Optional[Configuration] = None): def __init__(self, *, size: Size, config: Optional[Configuration] = None):
@ -57,7 +68,7 @@ class RoomsAndCorridorsGenerator(MapGenerator):
self.rng: tcod.random.Random = tcod.random.Random() self.rng: tcod.random.Random = tcod.random.Random()
self.rooms: List['RectanularRoom'] = [] self.rooms: List['RectangularRoom'] = []
self.tiles: Optional[np.ndarray] = None self.tiles: Optional[np.ndarray] = None
def generate(self) -> np.ndarray: def generate(self) -> np.ndarray:
@ -65,33 +76,69 @@ class RoomsAndCorridorsGenerator(MapGenerator):
return self.tiles return self.tiles
minimum_room_size = self.configuration.minimum_room_size 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. # 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=self.size.width, height=self.size.height)
bsp.split_recursive( bsp.split_recursive(
depth=4, depth=4,
min_width=minimum_room_size.width, min_height=minimum_room_size.height, # 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=1.5, max_vertical_ratio=1.5) max_horizontal_ratio=1.5, max_vertical_ratio=1.5)
tiles = np.full(tuple(self.size), fill_value=Wall, order='F') tiles = np.full(tuple(self.size), fill_value=Empty, order='F')
# Generate the rooms # Generate the rooms
rooms: List['RectangularRoom'] = [] rooms: List['RectangularRoom'] = []
# For nicer debug logging # For nicer debug logging
indent = 0 indent = 0
for node in bsp.pre_order():
room_attrname = f'{__class__.__name__}.room'
for node in bsp.post_order():
node_bounds = self.__rect_from_bsp_node(node) node_bounds = self.__rect_from_bsp_node(node)
if node.children: if node.children:
if LOG.getEffectiveLevel() == logging.DEBUG: LOG.debug(f'{" " * indent}{node_bounds}')
LOG.debug(f'{" " * indent}{node_bounds}')
indent += 2 left_room: RectangularRoom = getattr(node.children[0], room_attrname)
# TODO: Connect the two child rooms right_room: RectangularRoom = getattr(node.children[1], room_attrname)
left_room_bounds = left_room.bounds
right_room_bounds = right_room.bounds
LOG.debug(f'{" " * indent} left:{node.children[0]}, {left_room_bounds}')
LOG.debug(f'{" " * indent}right:{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.debug(f'{" " * indent}Digging tunnel between {start_point} and {end_point} with corner {corner}')
LOG.debug(f'{" " * indent}`-> start:{left_room_bounds}')
LOG.debug(f'{" " * indent}`-> end:{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
indent += 2
else: else:
LOG.debug(f'{" " * indent}{node_bounds} (room) {node}') LOG.debug(f'{" " * indent}{node_bounds} (room) {node}')
size = Size(self.rng.randint(5, min(15, max(5, node.width - 2))), # Generate a room size between minimum_room_size and maximum_room_size. The minimum value is
self.rng.randint(5, min(15, max(5, node.height - 2)))) # 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)), 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))) node.y + self.rng.randint(1, max(1, node.height - size.height - 1)))
bounds = Rect(origin, size) bounds = Rect(origin, size)
@ -99,16 +146,58 @@ class RoomsAndCorridorsGenerator(MapGenerator):
LOG.debug(f'{" " * indent}`-> {bounds}') LOG.debug(f'{" " * indent}`-> {bounds}')
room = RectangularRoom(bounds) room = RectangularRoom(bounds)
setattr(node, room_attrname, room)
rooms.append(room) rooms.append(room)
if LOG.getEffectiveLevel() == logging.DEBUG: if not hasattr(node.parent, room_attrname):
indent -= 2 setattr(node.parent, room_attrname, room)
elif random.random() < 0.5:
setattr(node.parent, room_attrname, room)
indent -= 2
# 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 self.rooms = rooms
for room in 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 bounds = room.bounds
tiles[bounds.min_x:bounds.max_x, bounds.min_y:bounds.max_y] = Floor # 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.North,
pos + Direction.NorthEast,
pos + Direction.East,
pos + Direction.SouthEast,
pos + Direction.South,
pos + Direction.SouthWest,
pos + Direction.West,
pos + Direction.NorthWest,
]
for neighbor in neighbors:
if tiles[neighbor.x, neighbor.y] != Empty:
continue
tiles[neighbor.x, neighbor.y] = Wall
self.tiles = tiles self.tiles = tiles
@ -129,5 +218,21 @@ class RectangularRoom:
def center(self) -> Point: def center(self) -> Point:
return self.bounds.midpoint return self.bounds.midpoint
@property
def floor_bounds(self) -> Rect:
return self.bounds.inset_rect(top=1, right=1, bottom=1, left=1)
@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: def __repr__(self) -> str:
return f'{self.__class__.__name__}({self.bounds})' return f'{self.__class__.__name__}({self.bounds})'

View file

@ -25,5 +25,6 @@ tile_datatype = np.dtype([
def tile(*, walkable: int, transparent: int, dark: Tuple[int, Tuple[int, int, int], Tuple[int, int ,int]]) -> np.ndarray: def tile(*, walkable: int, transparent: int, dark: Tuple[int, Tuple[int, int, int], Tuple[int, int ,int]]) -> np.ndarray:
return np.array((walkable, transparent, dark), dtype=tile_datatype) return np.array((walkable, transparent, dark), dtype=tile_datatype)
Empty = tile(walkable=False, transparent=False, dark=(ord(' '), (255, 255, 255), (0, 0, 0)))
Floor = tile(walkable=True, transparent=True, dark=(ord(' '), (255, 255, 255), (50, 50, 150))) Floor = tile(walkable=True, transparent=True, dark=(ord(' '), (255, 255, 255), (50, 50, 150)))
Wall = tile(walkable=False, transparent=False, dark=(ord(' '), (255, 255, 255), (0, 0, 150))) Wall = tile(walkable=False, transparent=False, dark=(ord(' '), (255, 255, 255), (0, 0, 150)))