Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ A small generator for 2D dungeon floors.
The algorithm is based on the floor generation of the game "The Binding of Isaac",
which is described in this [article](https://www.boristhebrave.com/2020/09/12/dungeon-generation-in-binding-of-isaac/).

A floor is a grid of 9 × 8 cells with a start room, normal rooms, dead ends and a boss room.
Depending on the layout it also contains an item room, a shop and secret rooms.
The same seed and floor id always produce the same floor.
A floor is a grid of 9 × 8 cells with a start room, normal rooms, dead ends, a boss room,
an item room, a shop, a secret room and a super secret room.
Like in the game, a floor is generated again until it has enough rooms
and dead ends for the special rooms.
The same seed and floor id always produce the same floor,
and every floor id produces a different floor for the same seed.

The generator can be used in three ways:

Expand All @@ -32,7 +35,7 @@ python src/main.py -s my-seed -f 3 -o my-floor
| Option | Description | Default |
|---|---|---|
| `-s`, `--seed` | Seed for the random number generator | random seed |
| `-f`, `--floor` | Floor id, must be 0 or greater. Higher floor ids get more rooms (at most 15) | `2` |
| `-f`, `--floor` | Floor id, must be 0 or greater. Higher floor ids get more rooms (8 to 9 on floor 0, up to 20) | `2` |
| `-o`, `--output` | Name of the output file | `floor` |
| `-u`, `--ui` | Opens the UI instead of writing a file | disabled |
| `-h`, `--help` | Shows the help | |
Expand Down Expand Up @@ -140,8 +143,10 @@ A negative floor id returns `422`.
| 5 | Teleport room |
| 6 | Boss room |
| 7 | Secret room |
| 8 | Super secret room, connected to only one room and placed close to the boss room |

A boss room can span up to 2 × 2 cells, every cell is stored as its own room.
Most boss rooms are a single cell, some span 1 × 2 or 2 × 2 cells
and every cell is stored as its own room.
On some floors the boss room is moved to a free cell away from the other rooms
and can only be reached through a teleport room.

Expand Down
150 changes: 91 additions & 59 deletions src/generators/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ def __init__(
self._floor: Floor = Floor(
my_globals.FLOOR_HEIGHT, my_globals.FLOOR_WIDTH, seed
)
self._random = random.Random(seed)
self._random = random.Random()
self._seed_random(seed)

def to_dict(self) -> dict:
"""
Expand All @@ -69,6 +70,14 @@ def to_json(self, indent: int = 1) -> str:
"""
return json.dumps(self.to_dict(), indent=len(my_globals.BASE_INDENT) * indent)

def _seed_random(self, seed: str) -> None:
"""
Seeds the random number generator with the seed and the floor id,
so the same seed produces different floors for different floor ids.
@param seed: seed for generating the floor
"""
self._random.seed(f"{seed}/{self._stage_id}")

def _create_floor(self) -> None:
"""
Creates a new floor.
Expand Down Expand Up @@ -101,51 +110,66 @@ def _add_new_room(
def generate(self, new_seed=None) -> None:
"""
Generates the rooms for the floor.
Like in "The Binding of Isaac", a floor is generated again if it does not
reach the room amount or has not enough dead ends for the special rooms.
"""
if new_seed is not None:
self._random.seed(new_seed)
self._seed_random(new_seed)
self._seed = new_seed
for attempt in range(my_globals.MAX_GENERATION_ATTEMPTS):
last_attempt = attempt == my_globals.MAX_GENERATION_ATTEMPTS - 1
start_room, complete = self._place_rooms()
dead_ends = self.mark_dead_ends()
if last_attempt or (
complete and len(dead_ends) >= my_globals.REQUIRED_DEAD_ENDS
):
break
self.add_boss_room(dead_ends, start_room)
self.add_special_rooms(dead_ends)
self.place_secret_room()
self.place_super_secret_room()
self._floor.add_doors_to_rooms()

def _place_rooms(self) -> Tuple[Tuple[int, int], bool]:
"""
Creates a new floor and places the start room and the normal rooms.
@return: position of the start room and
if the room amount for the floor has been reached
"""
self._create_floor()
floor = self._floor
number_of_rooms = util_functions.calculate_room_amount(
self._stage_id, self._random
)

# Add start room
start_room: tuple = (
start_room: Tuple[int, int] = (
self._random.randint(0, my_globals.FLOOR_WIDTH - 1),
self._random.randint(0, my_globals.FLOOR_HEIGHT - 1),
)
floor.add_room(start_room[0], start_room[1], RoomType.START_ROOM)
number_of_current_rooms = 1
room_tuple_queue: deque = deque([]) # Room coordinates
room_tuple_queue.append(start_room)
room_tuple_queue: deque = deque([start_room]) # Room coordinates
room_tuple_list = []
while number_of_current_rooms < number_of_rooms and len(room_tuple_queue) > 0:
room_tuple = room_tuple_queue.pop()
for direction in Direction.main_directions():
if number_of_current_rooms >= number_of_rooms:
break
new_room_tuple = util_functions.add_direction_to_coordinates(
direction, room_tuple
)
if self._add_new_room(new_room_tuple, room_tuple_queue):
number_of_current_rooms += 1
room_tuple_list.append(room_tuple)
if len(room_tuple_queue) == 0 and number_of_current_rooms < number_of_rooms:
room_tuple_queue.append(room_tuple_list.pop(0))

while len(room_tuple_queue) > 0:
room_tuple_list.append(room_tuple_queue.pop())
room_tuple_list.remove(start_room)
while len(room_tuple_list) > 0:
room_tuple = room_tuple_list.pop()
floor.add_room(room_tuple[0], room_tuple[1])

dead_ends = self.mark_dead_ends()
self.add_boss_room(dead_ends, start_room)
self.add_special_rooms(dead_ends)
self.place_secret_room()
self.place_super_secret_room()
floor.add_doors_to_rooms()
return start_room, number_of_current_rooms >= number_of_rooms

def mark_dead_ends(self) -> list:
"""
Expand Down Expand Up @@ -176,63 +200,71 @@ def _add_rooms_next_to_room(self, room, directions) -> None:

def add_boss_room(self, dead_end_indices: list, start_room: tuple) -> None:
"""
Marks a room as the boss room depending on the location of the start room.
Turns one of the dead ends farthest away from the start room into the boss room.
A dead end with free space next to it is preferred, otherwise the boss room
is moved away and connected to the floor with a teleport room.
@param dead_end_indices: indices for all dead ends
@param start_room: coordinates of the start room in a tuple
"""
if len(dead_end_indices) == 0:
return
floor = self._floor
boss_room: Room
possible_locations: list = []
boss_room_placed = False
boss_room = floor.get_rooms()[dead_end_indices[0]]
boss_room_index = dead_end_indices[0]
max_distance = 0
max_distance_index = 0
for index in dead_end_indices:
dead_end = floor.get_rooms()[index]
current_distance = util_functions.calculate_distance(start_room, dead_end)
if max_distance < current_distance:
max_distance = current_distance
max_distance_index = index

boss_room = floor.get_rooms()[max_distance_index]
boss_room_index = max_distance_index

boss_room_x = boss_room[0]
boss_room_y = boss_room[1]
for direction in Direction.main_directions():
new_boss_tuple = util_functions.add_direction_to_coordinates(
direction, (boss_room_x, boss_room_y)
)
if (
self._floor.is_within_border(new_boss_tuple)
and floor.count_neighbours(new_boss_tuple[0], new_boss_tuple[1]) == 1
and not self._floor.contains_room(new_boss_tuple)
):
possible_locations.append(direction)
rooms = floor.get_rooms()
farthest_first = sorted(
dead_end_indices,
key=lambda index: -util_functions.calculate_distance(
start_room, rooms[index]
),
)
candidates = farthest_first[: my_globals.BOSS_ROOM_CANDIDATES]
boss_room_index = next(
(index for index in candidates if self._free_boss_locations(rooms[index])),
farthest_first[0],
)
boss_room = rooms[boss_room_index]
possible_locations = self._free_boss_locations(boss_room)

if len(possible_locations) == 0:
# Place a teleport-room to the boss-room
self._place_boss_with_teleport_room(boss_room)
boss_room_placed = True

elif len(possible_locations) >= 2 and self._random.randint(0, 10) < 5:
# Create a 2 * 2 boss-room
boss_room_placed = self._place_big_boss_room(possible_locations, boss_room)

if (
not boss_room_placed
and len(possible_locations) >= 1
and self._random.randint(0, 10) < 5
):
# Create a 1 * 2 boss-room
floor.add_room_next_to(boss_room, possible_locations[0], RoomType.BOSS_ROOM)
else:
size_roll = self._random.random()
placed = (
len(possible_locations) >= 2
and size_roll < my_globals.BIG_BOSS_ROOM_CHANCE
and self._place_big_boss_room(possible_locations, boss_room)
)
if not placed and size_roll < (
my_globals.BIG_BOSS_ROOM_CHANCE + my_globals.LONG_BOSS_ROOM_CHANCE
):
# Create a 1 * 2 boss-room
floor.add_room_next_to(
boss_room, possible_locations[0], RoomType.BOSS_ROOM
)

boss_room.set_type(RoomType.BOSS_ROOM)
dead_end_indices.remove(boss_room_index)

def _free_boss_locations(self, room: Room) -> List[Direction]:
"""
Returns the directions in which the boss room could be extended.
@param room: possible boss room
@return: directions with a free cell that is only next to the room
"""
floor = self._floor
locations = []
for direction in Direction.main_directions():
cell = util_functions.add_direction_to_coordinates(
direction, (room[0], room[1])
)
if (
floor.is_within_border(cell)
and not floor.contains_room(cell)
and floor.count_neighbours(cell[0], cell[1]) == 1
):
locations.append(direction)
return locations

def _place_big_boss_room(self, possible_locations: List, boss_room: Room) -> bool:
"""
Searches a free location to add more boss rooms next to the existing boss room.
Expand Down Expand Up @@ -324,7 +356,7 @@ def place_super_secret_room(self) -> None:
It should only have one adjacent room.
"""
floor = self._floor
distance_to_boss = my_globals.MAX_SUPER_SECRET_ROOM_DISTANCE_SQUARED
distance_to_boss = float("inf")
candidate = None
boss_room = floor.get_boss_room()
for room in floor.get_rooms():
Expand All @@ -343,7 +375,7 @@ def place_super_secret_room(self) -> None:
distance_to_boss = distance
candidate = neighbour
if candidate is not None:
floor.add_room(candidate[0], candidate[1], RoomType.SECRET_ROOM)
floor.add_room(candidate[0], candidate[1], RoomType.SUPER_SECRET_ROOM)

def place_secret_room(self) -> None:
"""
Expand Down
2 changes: 1 addition & 1 deletion src/rooms/tkinter/legend_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from utils.room_type import RoomType, room_names

_ROW_HEIGHT = 44
_WIDTH = 230
_WIDTH = 260


class LegendPanel(ttk.Frame):
Expand Down
2 changes: 2 additions & 0 deletions src/rooms/tkinter/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class Palette:
RoomType.TELEPORT_ROOM: "#b28fd8",
RoomType.BOSS_ROOM: "#d9665f",
RoomType.SECRET_ROOM: "#7393cf",
RoomType.SUPER_SECRET_ROOM: "#5fb3b0",
},
)

Expand All @@ -88,6 +89,7 @@ class Palette:
RoomType.TELEPORT_ROOM: "#8a64b5",
RoomType.BOSS_ROOM: "#b84d47",
RoomType.SECRET_ROOM: "#4f6fae",
RoomType.SUPER_SECRET_ROOM: "#3f8f8b",
},
)

Expand Down
4 changes: 3 additions & 1 deletion src/rooms/tkinter/tkinter_room.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from utils.globals import DoorFace
from utils.room_type import RoomType

_SECRET_ROOMS = (RoomType.SECRET_ROOM, RoomType.SUPER_SECRET_ROOM)


def _rounded_rectangle(x0, y0, x1, y1, radius) -> List[float]:
"""
Expand Down Expand Up @@ -68,7 +70,7 @@ def draw(self, canvas: tk.Canvas):
fill=palette.rooms[self._type],
outline=palette.room_outline,
width=2,
dash=(4, 3) if self._type == RoomType.SECRET_ROOM else (),
dash=(4, 3) if self._type in _SECRET_ROOMS else (),
tags=("room",),
)
if self._type == RoomType.TELEPORT_ROOM:
Expand Down
14 changes: 10 additions & 4 deletions src/utils/globals.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,16 @@ def list(cls):
# Width and height in number of rooms
FLOOR_WIDTH: int = 9
FLOOR_HEIGHT: int = 8
MAX_ROOMS: int = 15
# The super secret room is only placed closer than this to the boss room
# (squared distance, 25 means a distance below 5 rooms)
MAX_SUPER_SECRET_ROOM_DISTANCE_SQUARED: int = 25
MAX_ROOMS: int = 20
# A floor is generated again if it has less dead ends than the boss, item and shop room need
REQUIRED_DEAD_ENDS: int = 3
# Upper limit for generating a floor again, the last attempt is always kept
MAX_GENERATION_ATTEMPTS: int = 200
# Chances for a 2 * 2 and a 1 * 2 boss room, otherwise the boss room is a single room
BIG_BOSS_ROOM_CHANCE: float = 0.1
LONG_BOSS_ROOM_CHANCE: float = 0.2
# Number of farthest dead ends that are tried for the boss room before a teleport is used
BOSS_ROOM_CANDIDATES: int = 2
# Distance between start and boss room
X_OFFSET = (FLOOR_WIDTH * ROOM_WIDTH) / 2
Y_OFFSET = 0
Expand Down
3 changes: 3 additions & 0 deletions src/utils/room_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def is_special(self) -> bool:
RoomType.SHOP_ROOM,
RoomType.BOSS_ROOM,
RoomType.SECRET_ROOM,
RoomType.SUPER_SECRET_ROOM,
)

NORMAL_ROOM = 0
Expand All @@ -51,6 +52,7 @@ def is_special(self) -> bool:
TELEPORT_ROOM = 5
BOSS_ROOM = 6
SECRET_ROOM = 7
SUPER_SECRET_ROOM = 8


room_names = {
Expand All @@ -62,6 +64,7 @@ def is_special(self) -> bool:
RoomType.TELEPORT_ROOM: "Teleport Room",
RoomType.BOSS_ROOM: "Boss Room",
RoomType.SECRET_ROOM: "Secret Room",
RoomType.SUPER_SECRET_ROOM: "Super Secret Room",
}

SPECIAL_ROOMS = (RoomType.ITEM_ROOM, RoomType.SHOP_ROOM)
12 changes: 8 additions & 4 deletions src/utils/util_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ def get_picture_for_room_type(room_type: RoomType):
# Go up two levels to the project root directory
project_root = os.path.dirname(os.path.dirname(current_dir))
# Define the relative path to the image file
# The super secret room uses the icon of the secret room
if room_type == RoomType.SUPER_SECRET_ROOM:
room_type = RoomType.SECRET_ROOM
image_file = f"{room_type.name.lower()}.png"
# Join the project root directory with the relative path to get the absolute path
image_path = os.path.join(project_root, "resources", "room_icons", image_file)
Expand All @@ -35,14 +38,15 @@ def get_picture_for_room_type(room_type: RoomType):

def calculate_room_amount(stage_id: int, rng: random.Random) -> int:
"""
Calculates the room amount.
The generator may place a few more rooms than this,
because it adds all neighbours of a room in one step.
Calculates the amount of rooms the generator places,
without the boss extension, secret and teleport rooms.
@param stage_id: id for the floor
@param rng: random number generator to use
:return: room amount
"""
return min(my_globals.MAX_ROOMS, rng.randint(0, 1) + 5 + stage_id * 10 // 3)
# Same formula as in "The Binding of Isaac", where the first floor is stage 1
stage = stage_id + 1
return min(my_globals.MAX_ROOMS, rng.randint(0, 1) + 5 + stage * 10 // 3)


def place_room(rng: random.Random) -> bool:
Expand Down
Loading
Loading