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
3 changes: 2 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ A number based on the current time is appended to the file name, e.g. `generatio
python src/main.py --ui
```

The UI draws a generated floor room by room, including the doors between the rooms
The UI draws a generated floor step by step, spreading from the start room through the doors
and revealing the secret rooms last. It shows the doors between the rooms
and an arrow from a teleport room to its target. Every generated floor is kept,
so you can switch between them. A legend next to the floor shows the room types
and how often they appear on the current floor.
Expand Down
65 changes: 65 additions & 0 deletions src/floors/floor.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@
from rooms.room import Room
from rooms.teleport_room import TeleportRoom

_DOOR_DIRECTIONS = {
DoorFace.TOP: Direction.UP,
DoorFace.EAST: Direction.RIGHT,
DoorFace.BOTTOM: Direction.DOWN,
DoorFace.WEST: Direction.LEFT,
}


class Floor:
"""
Expand Down Expand Up @@ -285,6 +292,64 @@ def has_special_room_as_neighbour(self, coordinates: Tuple[int, int]) -> bool:
return True
return False

def rooms_by_distance(self) -> List[List[Room]]:
"""
Groups the rooms by how many doors away from the start room they are,
following teleport rooms to their target.
All cells of a big boss room are in the same group
and secret rooms are hidden, so they come last.
@return: rings of rooms, starting with the start room
"""
hidden_types = (RoomType.SECRET_ROOM, RoomType.SUPER_SECRET_ROOM)
rooms_by_id = {room.get_id(): room for room in self._rooms}
start = [r for r in self._rooms if r.get_type() == RoomType.START_ROOM]
seen = {room.get_id() for room in start}
rings: List[List[Room]] = [start] if start else []
while rings and rings[-1]:
next_ring: List[Room] = []
for room in rings[-1]:
for neighbour in self._connected_rooms(room, rooms_by_id):
if (
neighbour.get_id() not in seen
and neighbour.get_type() not in hidden_types
):
seen.add(neighbour.get_id())
next_ring.append(neighbour)
if neighbour.get_type() == RoomType.BOSS_ROOM:
# All cells of a big boss room appear together
for boss_cell in self._rooms:
if (
boss_cell.get_type() == RoomType.BOSS_ROOM
and boss_cell.get_id() not in seen
):
seen.add(boss_cell.get_id())
next_ring.append(boss_cell)
rings.append(sorted(next_ring, key=lambda r: r.get_id()))
rings = [ring for ring in rings if ring]
remaining = [room for room in self._rooms if room.get_id() not in seen]
if remaining:
rings.append(sorted(remaining, key=lambda r: r.get_id()))
return rings

def _connected_rooms(self, room: Room, rooms_by_id: dict) -> List[Room]:
"""
Returns the rooms behind the doors of a room and the target of a teleport.
"""
connected = []
for door in room.get_doors():
neighbour = self.get_room(
utils.util_functions.add_direction_to_coordinates(
_DOOR_DIRECTIONS[door], (room[0], room[1])
)
)
if neighbour is not None:
connected.append(neighbour)
if isinstance(room, TeleportRoom):
target = rooms_by_id.get(room.get_connected_room_id())
if target is not None:
connected.append(target)
return connected

def get_boss_room(self) -> Optional[Room]:
"""
Returns the boss room of the floor.
Expand Down
24 changes: 13 additions & 11 deletions src/floors/tkinter_floor.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,25 +174,27 @@ def stop_drawing(self) -> None:

def draw_step_by_step(self, delay_ms: int = 500) -> None:
"""
Draws the floor room by room using the tkinter event loop.
@param delay_ms: delay between two rooms in milliseconds
Draws the floor step by step using the tkinter event loop,
starting with the start room and spreading through the doors.
@param delay_ms: delay between two steps in milliseconds
"""
self.stop_drawing()
self._draw_background()
sorted_rooms = sorted(self._tkinter_rooms(), key=lambda r: r.get_id())
self._draw_next_room(sorted_rooms, delay_ms)
rings = cast(List[List[TkinterRoom]], self.rooms_by_distance())
self._draw_next_ring(rings, delay_ms)

def _draw_next_room(self, rooms: List[TkinterRoom], delay_ms: int) -> None:
def _draw_next_ring(self, rings: List[List[TkinterRoom]], delay_ms: int) -> None:
"""
Draws the first of the given rooms and schedules the remaining rooms.
@param rooms: rooms that still have to be drawn
@param delay_ms: delay between two rooms in milliseconds
Draws the first of the given rings of rooms and schedules the remaining rings.
@param rings: rings of rooms that still have to be drawn
@param delay_ms: delay between two rings in milliseconds
"""
self._after_id = None
if len(rooms) == 0:
if len(rings) == 0:
self._draw_teleport_links()
return
rooms[0].draw(self._canvas)
for room in rings[0]:
room.draw(self._canvas)
self._after_id = self._canvas.after(
delay_ms, self._draw_next_room, rooms[1:], delay_ms
delay_ms, self._draw_next_ring, rings[1:], delay_ms
)
18 changes: 13 additions & 5 deletions src/generators/tkinter_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
import secrets
import tkinter as tk
from typing import Callable, List, Optional, Union
from tkinter import filedialog, messagebox
from tkinter import filedialog
from tkinter import ttk

from floors.floor import Floor
from floors.tkinter_floor import TkinterFloor
from generators.floor_manager import FloorManager
from generators.generator import Generator
from generators.theme_handler import ThemeHandler
from rooms.tkinter import dialog
from rooms.tkinter.legend_panel import LegendPanel
from rooms.tkinter.tooltip import RoomToolTip, ToolTip
from utils import util_functions, globals as my_globals
Expand Down Expand Up @@ -159,8 +160,13 @@ def save(self, path: str = "") -> str:
# Check if the file already exists
if os.path.exists(path):
# Show dialog for overwriting the file
if messagebox.askyesno(
"File already exists", "Do you want to overwrite the file?"
if dialog.ask(
self._tk,
"File already exists",
f"{os.path.basename(path)} already exists. "
"Do you want to overwrite it?",
"Overwrite",
"Cancel",
):
save_file = True
else:
Expand All @@ -169,7 +175,7 @@ def save(self, path: str = "") -> str:
# Save the file to the given path
path = super().save(path)
# Show dialog for saving the file
messagebox.showinfo("Save", "File saved successfully!")
dialog.inform(self._tk, "Save", "File saved successfully!")

return path
options = util_functions.json_file_options.copy()
Expand Down Expand Up @@ -205,7 +211,9 @@ def quit(self) -> None:
"""
Shows a dialog for quitting the application.
"""
if messagebox.askyesno("Quit", "Do you really want to quit?"):
if dialog.ask(
self._tk, "Quit", "Do you really want to quit?", "Quit", "Cancel"
):
self._tk.quit()

def _redraw_floor(self) -> None:
Expand Down
116 changes: 116 additions & 0 deletions src/rooms/tkinter/dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""
Simple dialogs in the style of the application.
Native message boxes use the language of the operating system for their buttons,
so they would mix English texts with e.g. German buttons and ignore the dark theme.
"""

import tkinter as tk
from tkinter import ttk
from typing import Optional

from rooms.tkinter import style


class Dialog:
"""
Modal dialog with a message and one or two buttons.
"""

def __init__(
self,
parent: tk.Misc,
title: str,
message: str,
confirm_text: str,
cancel_text: Optional[str] = None,
) -> None:
"""
Creates the dialog without showing it.
@param parent: window the dialog belongs to
@param title: title of the dialog window
@param message: message to show
@param confirm_text: text of the confirm button
@param cancel_text: text of the cancel button, None for no cancel button
"""
self.result = False
palette = style.get_palette()
self.window = tk.Toplevel(parent, background=palette.window)
self.window.title(title)
self.window.resizable(False, False)
self.window.transient(parent.winfo_toplevel())

content = ttk.Frame(self.window, padding=(20, 16, 20, 14))
content.pack(fill=tk.BOTH)
ttk.Label(content, text=message, wraplength=360).pack(anchor=tk.W)
buttons = ttk.Frame(content)
buttons.pack(anchor=tk.E, pady=(16, 0))
self.confirm_button = ttk.Button(
buttons, text=confirm_text, command=self.confirm
)
self.confirm_button.pack(side=tk.RIGHT)
self.cancel_button: Optional[ttk.Button] = None
if cancel_text is not None:
self.cancel_button = ttk.Button(
buttons, text=cancel_text, command=self.cancel
)
self.cancel_button.pack(side=tk.RIGHT, padx=(0, 8))

self.window.bind("<Return>", lambda event: self.confirm())
self.window.bind("<Escape>", lambda event: self.cancel())
self.window.protocol("WM_DELETE_WINDOW", self.cancel)
self._center_over(parent.winfo_toplevel())

def _center_over(self, parent: tk.Misc) -> None:
self.window.update_idletasks()
x = (
parent.winfo_rootx()
+ (parent.winfo_width() - self.window.winfo_reqwidth()) // 2
)
y = (
parent.winfo_rooty()
+ (parent.winfo_height() - self.window.winfo_reqheight()) // 3
)
self.window.geometry(f"+{max(x, 0)}+{max(y, 0)}")

def confirm(self) -> None:
"""
Closes the dialog with a positive result.
"""
self.result = True
self.window.destroy()

def cancel(self) -> None:
"""
Closes the dialog with a negative result.
"""
self.result = False
self.window.destroy()

def show(self) -> bool:
"""
Shows the dialog and waits until it is closed.
@return: True if the dialog was confirmed
"""
# Wait until the dialog is visible, then keep all input in the dialog
self.window.wait_visibility()
self.window.grab_set()
self.confirm_button.focus_force()
self.window.wait_window()
return self.result


def ask(
parent: tk.Misc, title: str, message: str, confirm_text: str, cancel_text: str
) -> bool:
"""
Asks a question with a confirm and a cancel button.
@return: True if the question was confirmed
"""
return Dialog(parent, title, message, confirm_text, cancel_text).show()


def inform(parent: tk.Misc, title: str, message: str) -> None:
"""
Shows a message with an OK button.
"""
Dialog(parent, title, message, "OK").show()
45 changes: 45 additions & 0 deletions test/floor_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,51 @@ def test_is_dead_end(self):
def test_is_dead_end_false(self):
self.assertFalse(self._floor_with_rooms.is_dead_end(2, 2))

def test_rooms_by_distance_spreads_from_the_start_room(self):
# pylint: disable=import-outside-toplevel
from generators.generator import Generator
from rooms.teleport_room import TeleportRoom

hidden = (RoomType.SECRET_ROOM, RoomType.SUPER_SECRET_ROOM)
for stage_id in (0, 2, 5):
for seed in range(60):
with self.subTest(stage_id=stage_id, seed=seed):
generator = Generator(str(seed), "test", "", stage_id)
generator.generate()
floor = generator._floor
rings = floor.rooms_by_distance()

ids = [room.get_id() for ring in rings for room in ring]
self.assertEqual(
sorted(r.get_id() for r in floor.get_rooms()), sorted(ids)
)
self.assertEqual(
[RoomType.START_ROOM], [r.get_type() for r in rings[0]]
)
self.assertTrue(all(r.get_type() in hidden for r in rings[-1]))
boss_rings = {
index
for index, ring in enumerate(rings)
for room in ring
if room.get_type() == RoomType.BOSS_ROOM
}
self.assertEqual(1, len(boss_rings))
# Every visible room is reached from the ring before it
for index in range(1, len(rings) - 1):
previous = rings[index - 1]
for room in rings[index]:
if room.get_type() == RoomType.BOSS_ROOM:
continue
reached = any(
abs(room[0] - other[0]) + abs(room[1] - other[1]) == 1
or (
isinstance(other, TeleportRoom)
and other.get_connected_room_id() == room.get_id()
)
for other in previous
)
self.assertTrue(reached)


if __name__ == "__main__":
unittest.main()
Loading
Loading