Add comments and docstring

This commit is contained in:
Charles Peyrat 2021-01-10 22:54:48 +01:00
parent 13e3628668
commit 099a0eab31
1 changed files with 109 additions and 7 deletions

View File

@ -27,6 +27,10 @@ DEFAULT_PARAMS = {
} }
def dist(level, y1, x1, y2, x2): def dist(level, y1, x1, y2, x2):
"""
Compute the minimum walking distance between points (y1, x1) and (y2, x2) on a Tile grid
"""
# simple breadth first search
copy = [[t for t in l] for l in level] copy = [[t for t in l] for l in level]
dist = -1 dist = -1
queue, next_queue = [[y1, x1]], [0] queue, next_queue = [[y1, x1]], [0]
@ -54,12 +58,19 @@ class Generator:
def room_fits(level: List[List[Tile]], y: int, x: int, def room_fits(level: List[List[Tile]], y: int, x: int,
room: List[List[Tile]], door_y: int, door_x: int, room: List[List[Tile]], door_y: int, door_x: int,
dy: int, dx: int) -> bool: dy: int, dx: int) -> bool:
"""
Using point (door_y, door_x) in the room as a reference and placing it
over point (y, x) in the level, returns whether or not the room fits
here
"""
lh, lw = len(level), len(level[0]) lh, lw = len(level), len(level[0])
rh, rw = len(room), len(room[0]) rh, rw = len(room), len(room[0])
if not(0 < y + dy < lh and 0 < x + dx < lw): if not(0 < y + dy < lh and 0 < x + dx < lw):
return False return False
# door must be placed on an empty tile, and point into a floor tile
if level[y][x] != Tile.EMPTY or level[y + dy][x + dx] != Tile.FLOOR: if level[y][x] != Tile.EMPTY or level[y + dy][x + dx] != Tile.FLOOR:
return False return False
# now we verify floor tiles in both grids do not overlap
for ry in range(rh): for ry in range(rh):
for rx in range(rw): for rx in range(rw):
if room[ry][rx] == Tile.FLOOR: if room[ry][rx] == Tile.FLOOR:
@ -80,8 +91,12 @@ class Generator:
@staticmethod @staticmethod
def place_room(level: List[List[Tile]], y: int, x: int, def place_room(level: List[List[Tile]], y: int, x: int,
room: List[List[Tile]], door_y: int, door_x: int) -> None: room: List[List[Tile]], door_y: int, door_x: int) -> None:
"""
Mutates level in place to add the room. Placement is determined by
making (door_y, door_x) in the room correspond with (y, x) in the level
"""
rh, rw = len(room), len(room[0]) rh, rw = len(room), len(room[0])
# maybe place Tile.DOOR here ? # maybe place Tile.DOOR here instead ?
level[y][x] = Tile.FLOOR level[y][x] = Tile.FLOOR
for ry in range(rh): for ry in range(rh):
for rx in range(rw): for rx in range(rw):
@ -90,12 +105,20 @@ class Generator:
@staticmethod @staticmethod
def add_loop(level: List[List[Tile]], y: int, x: int) -> None: def add_loop(level: List[List[Tile]], y: int, x: int) -> None:
"""
Try to add a corridor between two far apart floor tiles, passing
through point (y, x).
"""
h, w = len(level), len(level[0]) h, w = len(level), len(level[0])
if level[y][x] != Tile.EMPTY: if level[y][x] != Tile.EMPTY:
return False return False
# loop over both axis
# loop over both directions, trying to place both veritcal
# and horizontal corridors
for dx, dy in [[0, 1], [1, 0]]: for dx, dy in [[0, 1], [1, 0]]:
# then we find two floor tiles, exiting if we ever move oob # then we find two floor tiles, one on each side of (y, x)
# exiting if we don't find two (reach the edge of the map before)
y1, x1, y2, x2 = y, x, y, x y1, x1, y2, x2 = y, x, y, x
while x1 >= 0 and y1 >= 0 and level[y1][x1] == Tile.EMPTY: while x1 >= 0 and y1 >= 0 and level[y1][x1] == Tile.EMPTY:
y1, x1 = y1 - dy, x1 - dx y1, x1 = y1 - dy, x1 - dx
@ -104,15 +127,18 @@ class Generator:
if not(0 <= x1 <= x2 < w and 0 <= y1 <= y2 < h): if not(0 <= x1 <= x2 < w and 0 <= y1 <= y2 < h):
continue continue
# if adding the path would make the two tiles significantly closer
# and its sides don't touch already placed terrain, build it
def verify_sides(): def verify_sides():
# switching up dy and dx here pivots the axis, so
# (y+dx, x+dy) and (y-dx, x-dy) are the tiles adjacent to
# (y, x), but not on the original axis
for Dx, Dy in [[dy, dx], [-dy, -dx]]: for Dx, Dy in [[dy, dx], [-dy, -dx]]:
for i in range(1, y2-y1+x2-x1): for i in range(1, y2-y1+x2-x1):
if not(0<= y1+Dy+i*dy < h and 0 <= x1+Dx+i*dx < w) or \ if not(0<= y1+Dy+i*dy < h and 0 <= x1+Dx+i*dx < w) or \
level[y1+Dy+i*dy][x1+Dx+i*dx].can_walk(): level[y1+Dy+i*dy][x1+Dx+i*dx].can_walk():
return False return False
return True return True
# if adding the path would make the two tiles significantly closer
# and its sides don't touch already placed terrain, build it
if dist(level, y1, x1, y2, x2) < 20 and verify_sides(): if dist(level, y1, x1, y2, x2) < 20 and verify_sides():
y, x = y1+dy, x1+dx y, x = y1+dy, x1+dx
while level[y][x] == Tile.EMPTY: while level[y][x] == Tile.EMPTY:
@ -123,6 +149,10 @@ class Generator:
@staticmethod @staticmethod
def place_walls(level: List[List[Tile]]) -> None: def place_walls(level: List[List[Tile]]) -> None:
"""
Place wall tiles on every empty tile that is adjacent (in the largest
sense), to a floor tile
"""
h, w = len(level), len(level[0]) h, w = len(level), len(level[0])
for y in range(h): for y in range(h):
for x in range(w): for x in range(w):
@ -132,11 +162,25 @@ class Generator:
level[ny][nx] = Tile.WALL level[ny][nx] = Tile.WALL
def corr_meta_info(self) -> Tuple[int, int, int, int]: def corr_meta_info(self) -> Tuple[int, int, int, int]:
"""
Return info about the extra grid space that should be allocated for the
room, and where the room should be built along this extra grid space.
Because grids are usually thight around the room, this gives us extra
place to add a corridor later. Corridor length and orientation is
implicitly derived from this info.
h_sup and w_sup represent the extra needed space along each axis,
and h_off and w_off are the offset at which to build the room
"""
if random() < self.params["corridor_chance"]: if random() < self.params["corridor_chance"]:
h_sup = randint(self.params["min_v_corr"], h_sup = randint(self.params["min_v_corr"],
self.params["max_v_corr"]) if random() < .5 else 0 self.params["max_v_corr"]) if random() < .5 else 0
# we only allow extra space allocation along one axis,
# because there won't more than one exit corridor
w_sup = 0 if h_sup else randint(self.params["min_h_corr"], w_sup = 0 if h_sup else randint(self.params["min_h_corr"],
self.params["max_h_corr"]) self.params["max_h_corr"])
# implicitly choose which direction along the axis
# the corridor will be pointing to
h_off = h_sup if random() < .5 else 0 h_off = h_sup if random() < .5 else 0
w_off = w_sup if random() < .5 else 0 w_off = w_sup if random() < .5 else 0
return h_sup, w_sup, h_off, w_off return h_sup, w_sup, h_off, w_off
@ -144,6 +188,12 @@ class Generator:
@staticmethod @staticmethod
def build_door(room, y, x, dy, dx, length): def build_door(room, y, x, dy, dx, length):
"""
Tries to build the exit from the room at given coordinates
Depending on parameter length, it will either attempt to build a
simple door, or a long corridor. Return value is a boolean
signifying whether or not the exit was successfully built
"""
rh, rw = len(room), len(room[0]) rh, rw = len(room), len(room[0])
# verify we are pointing away from a floor tile # verify we are pointing away from a floor tile
if not(0 <= y - dy < rh and 0 <= x - dx < rw) \ if not(0 <= y - dy < rh and 0 <= x - dx < rw) \
@ -155,6 +205,7 @@ class Generator:
if 0 <= ny < rh and 0 <= nx < rw \ if 0 <= ny < rh and 0 <= nx < rw \
and room[ny][nx] != Tile.EMPTY: and room[ny][nx] != Tile.EMPTY:
return False return False
# see if the path ahead is clear. needed in the case of non convex room
for i in range(length+1): for i in range(length+1):
if room[y + i * dy][x + i * dx] != Tile.EMPTY: if room[y + i * dy][x + i * dx] != Tile.EMPTY:
return False return False
@ -165,6 +216,10 @@ class Generator:
@staticmethod @staticmethod
def attach_door(room: List[List[Tile]], h_sup: int, w_sup: int, def attach_door(room: List[List[Tile]], h_sup: int, w_sup: int,
h_off: int, w_off: int) -> Tuple[int, int, int, int]: h_off: int, w_off: int) -> Tuple[int, int, int, int]:
"""
Attach an exit to the room. If extra space was allocated to
the grid, make sure a corridor is properly built
"""
length = h_sup + w_sup length = h_sup + w_sup
dy, dx = 0, 0 dy, dx = 0, 0
if length > 0: if length > 0:
@ -173,11 +228,13 @@ class Generator:
else: else:
dx = -1 if w_off else 1 dx = -1 if w_off else 1
else: else:
# determine door direction for rooms without corridors
if random() < .5: if random() < .5:
dy = -1 if random() < .5 else 1 dy = -1 if random() < .5 else 1
else: else:
dx = -1 if random() < .5 else 1 dx = -1 if random() < .5 else 1
# loop over all possible positions in a random order
rh, rw = len(room), len(room[0]) rh, rw = len(room), len(room[0])
yxs = [i for i in range(rh * rw)] yxs = [i for i in range(rh * rw)]
shuffle(yxs) shuffle(yxs)
@ -186,11 +243,18 @@ class Generator:
if room[y][x] == Tile.EMPTY and \ if room[y][x] == Tile.EMPTY and \
Generator.build_door(room, y, x, dy, dx, length): Generator.build_door(room, y, x, dy, dx, length):
break break
else:
return None, None
return y + length * dy, x + length * dx, dy, dx return y + length * dy, x + length * dx, dy, dx
def create_circular_room(self, spawnable: bool = True) \ def create_circular_room(self, spawnable: bool = True) \
-> Tuple[List[List[Tile]], int, int, int, int]: -> Tuple[List[List[Tile]], int, int, int, int]:
"""
Create and return as a tile grid a room that is circular in shape, and
may have a center, also circular hole
Also return door info so we know how to place the room in the level
"""
if random() < self.params["large_circular_room"]: if random() < self.params["large_circular_room"]:
r = randint(5, 10) r = randint(5, 10)
else: else:
@ -217,8 +281,11 @@ class Generator:
else: else:
room[-1].append(Tile.EMPTY) room[-1].append(Tile.EMPTY)
# log all placed tiles as spawn positions
if spawnable: if spawnable:
self.register_spawn_area(room) self.register_spawn_area(room)
# attach exit
door_y, door_x, dy, dx = self.attach_door(room, h_sup, w_sup, door_y, door_x, dy, dx = self.attach_door(room, h_sup, w_sup,
h_off, w_off) h_off, w_off)
@ -226,9 +293,18 @@ class Generator:
def create_random_room(self, spawnable: bool = True) \ def create_random_room(self, spawnable: bool = True) \
-> Tuple[List[list], int, int, int, int]: -> Tuple[List[list], int, int, int, int]:
"""
Randomly select a room shape and return one such room along with its
door info. Set spawnable to False is the room should be marked as a
potential spawning region on the map
"""
return self.create_circular_room() return self.create_circular_room()
def register_spawn_area(self, area:List[List[Tile]]): def register_spawn_area(self, area:List[List[Tile]]):
"""
Register all floor positions relative to the input grid
for later use
"""
spawn_positions = [] spawn_positions = []
for y, line in enumerate(area): for y, line in enumerate(area):
for x, tile in enumerate(line): for x, tile in enumerate(line):
@ -237,12 +313,22 @@ class Generator:
self.queued_area = spawn_positions self.queued_area = spawn_positions
def update_spawnable(self, y, x): def update_spawnable(self, y, x):
"""
Convert previous spawn positions relative to the room grid to actual
actual spawn positions on the level grid, using the position of the
top left corner of the room on the level, then log them as a
spawnable region
"""
if self.queued_area != None: if self.queued_area != None:
translated_area = [[y+ry, x+rx] for ry, rx in self.queued_area] translated_area = [[y+ry, x+rx] for ry, rx in self.queued_area]
self.spawn_areas.append(translated_area) self.spawn_areas.append(translated_area)
self.queued_area = None self.queued_area = None
def populate(self, rv): def populate(self, rv):
"""
Populate every spawnable area with some randomly chosen, randomly
placed entity
"""
min_c, max_c = self.params["spawn_per_region"] min_c, max_c = self.params["spawn_per_region"]
for region in self.spawn_areas: for region in self.spawn_areas:
entity_count = randint(min_c, max_c) entity_count = randint(min_c, max_c)
@ -254,6 +340,10 @@ class Generator:
rv.add_entity(entity) rv.add_entity(entity)
def run(self) -> Map: def run(self) -> Map:
"""
Using procedural generation, build and return a full map, populated
with entities
"""
height, width = self.params["height"], self.params["width"] height, width = self.params["height"], self.params["width"]
level = [width * [Tile.EMPTY] for _ignored in range(height)] level = [width * [Tile.EMPTY] for _ignored in range(height)]
@ -261,24 +351,30 @@ class Generator:
mem, self.params["corridor_chance"] = self.params["corridor_chance"], 0 mem, self.params["corridor_chance"] = self.params["corridor_chance"], 0
starting_room, _, _, _, _ = self.create_random_room(spawnable = False) starting_room, _, _, _, _ = self.create_random_room(spawnable = False)
dim_v, dim_h = len(starting_room), len(starting_room[0]) dim_v, dim_h = len(starting_room), len(starting_room[0])
# because Generator.room_fits checks that the exit door is correctly
# placed, but the starting room has no exit door, we find a positoin
# manually
pos_y, pos_x = randint(0, height - dim_v - 1),\ pos_y, pos_x = randint(0, height - dim_v - 1),\
randint(0, width - dim_h - 1) randint(0, width - dim_h - 1)
self.place_room(level, pos_y, pos_x, starting_room, 0, 0) self.place_room(level, pos_y, pos_x, starting_room, 0, 0)
# remove the door that was placed
if starting_room[0][0] != Tile.FLOOR: if starting_room[0][0] != Tile.FLOOR:
level[pos_y][pos_x] = Tile.EMPTY level[pos_y][pos_x] = Tile.EMPTY
self.params["corridor_chance"] = mem self.params["corridor_chance"] = mem
# find a starting position # find a starting position for the player
sy, sx = randint(0, height - 1), randint(0, width - 1) sy, sx = randint(0, height - 1), randint(0, width - 1)
while level[sy][sx] != Tile.FLOOR: while level[sy][sx] != Tile.FLOOR:
sy, sx = randint(0, height - 1), randint(0, width - 1) sy, sx = randint(0, height - 1), randint(0, width - 1)
level[sy][sx] = Tile.LADDER level[sy][sx] = Tile.LADDER
# now we loop until we've tried enough, or we've added enough rooms # now we loop until we're bored, or we've added enough rooms
tries, rooms_built = 0, 0 tries, rooms_built = 0, 0
while tries < self.params["tries"] \ while tries < self.params["tries"] \
and rooms_built < self.params["max_rooms"]: and rooms_built < self.params["max_rooms"]:
# build a room, try to fit it everywhere in a random order, and
# place it at the first possible position
room, door_y, door_x, dy, dx = self.create_random_room() room, door_y, door_x, dy, dx = self.create_random_room()
positions = [i for i in range(height * width)] positions = [i for i in range(height * width)]
shuffle(positions) shuffle(positions)
@ -293,6 +389,12 @@ class Generator:
# post-processing # post-processing
self.place_walls(level) self.place_walls(level)
# because when a room is placed, it leads to exactly one previously
# placed room, the level has a tree like structure with the starting
# room as the root
# to avoid boring player backtracking, we add some cycles to the room
# graph in post processing by placing additional corridors
tries, loops = 0, 0 tries, loops = 0, 0
while tries < self.params["loop_tries"] and \ while tries < self.params["loop_tries"] and \
loops < self.params["loop_max"]: loops < self.params["loop_max"]: