diff --git a/.gitignore b/.gitignore index 45c9f53..41b017e 100644 --- a/.gitignore +++ b/.gitignore @@ -264,3 +264,6 @@ dmypy.json cython_debug/ # End of https://www.toptal.com/developers/gitignore/api/python,jetbrains+all,macos + +# Don't save game saves to git! +savegame.sav diff --git a/engine.py b/engine.py index deec79c..9f9127b 100644 --- a/engine.py +++ b/engine.py @@ -1,5 +1,7 @@ from __future__ import annotations +import lzma +import pickle from typing import TYPE_CHECKING from tcod.console import Console @@ -54,3 +56,9 @@ class Engine: ) render_names_at_mouse_location(console, x=21, y=44, engine=self) + + def save_as(self, filename: str) -> None: + """Save this Engine instance as a compressed file.""" + save_data = lzma.compress(pickle.dumps(self)) + with open(filename, "wb") as f: + f.write(save_data) diff --git a/input_handlers.py b/input_handlers.py index 1d17a2b..50814af 100644 --- a/input_handlers.py +++ b/input_handlers.py @@ -89,6 +89,33 @@ class BaseEventHandler(tcod.event.EventDispatch[ActionOrHandler]): raise SystemExit() +class PopupMessage(BaseEventHandler): + """Display a popup text window.""" + + def __init__(self, parent_handler: BaseEventHandler, text: str): + self.parent = parent_handler + self.text = text + + def on_render(self, console: tcod.Console) -> None: + """Render the parent and dim the result, then print the message on top.""" + self.parent.on_render(console) + console.tiles_rgb["fg"] //= 8 + console.tiles_rgb["bg"] //= 8 + + console.print( + console.width // 2, + console.height // 2, + self.text, + fg=color.white, + bg=color.black, + alignment=tcod.CENTER, + ) + + def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseEventHandler]: + """Any key returns to the parent handler.""" + return self.parent + + class EventHandler(BaseEventHandler): def __init__(self, engine: Engine): self.engine = engine diff --git a/main.py b/main.py index 238fe9f..7f61b0b 100755 --- a/main.py +++ b/main.py @@ -1,31 +1,25 @@ #!/usr/bin/env python3 -import copy import traceback import tcod import color -from engine import Engine -import entity_factories import exceptions import input_handlers -from procgen import generate_dungeon +import setup_game + + +def save_game(handler: input_handlers.BaseEventHandler, filename: str) -> None: + """If the current event handler has an active Engine then save it.""" + if isinstance(handler, input_handlers.EventHandler): + handler.engine.save_as(filename) + print("Game saved.") def main() -> None: screen_width = 80 screen_height = 50 - map_width = 80 - map_height = 43 - - room_max_size = 10 - room_min_size = 6 - max_rooms = 30 - - max_monsters_per_room = 2 - max_items_per_room = 2 - tileset = tcod.tileset.load_tilesheet( "dejavu10x10_gs_tc.png", 32, @@ -33,28 +27,7 @@ def main() -> None: tcod.tileset.CHARMAP_TCOD ) - player = copy.deepcopy(entity_factories.player) - - engine = Engine(player) - - engine.game_map = generate_dungeon( - max_rooms, - room_min_size, - room_max_size, - map_width, - map_height, - max_monsters_per_room, - max_items_per_room, - engine, - ) - engine.update_fov() - - engine.message_log.add_message( - "Hello and welcome, adventurer, to yet another dungeon!", - color.welcome_text - ) - - handler: input_handlers.BaseEventHandler = input_handlers.MainGameEventHandler(engine) + handler: input_handlers.BaseEventHandler = setup_game.MainMenu() with tcod.context.new_terminal( screen_width, @@ -67,25 +40,29 @@ def main() -> None: try: while True: root_console.clear() - engine.event_handler.on_render(console=root_console) + handler.on_render(console=root_console) context.present(root_console) try: for event in tcod.event.wait(): context.convert_event(event) - engine.event_handler.handle_events(event) + handler = handler.handle_events(event) except Exception: # Handle exceptions in game. traceback.print_exc() # Print error to stderr. # Then print the error to the message log. - engine.message_log.add_message(traceback.format_exc(), color.error) + if isinstance(handler, input_handlers.EventHandler): + handler.engine.message_log.add_message( + traceback.format_exc(), + color.error, + ) except exceptions.QuitWithoutSaving: raise except SystemExit: # Save and quit. - # TODO: Add the save function here + save_game(handler, "savegame.sav") raise except BaseException: # Save on any other unexpected exception. - # TODO: Add the save function here + save_game(handler, "savegame.sav") raise diff --git a/menu_background.png b/menu_background.png new file mode 100644 index 0000000..70b9c20 Binary files /dev/null and b/menu_background.png differ diff --git a/setup_game.py b/setup_game.py new file mode 100644 index 0000000..1d69175 --- /dev/null +++ b/setup_game.py @@ -0,0 +1,119 @@ +"""Handle the loading and initialization of game sessions.""" +from __future__ import annotations + +import copy +import lzma +import pickle +import traceback +from typing import Optional + +import tcod + +import color +from engine import Engine +import entity_factories +import input_handlers +from procgen import generate_dungeon + +# Load the background image and remove the alpha channel. +background_image = tcod.image.load("menu_background.png")[:, :, :3] + + +def new_game() -> Engine: + """Return a brand new game session as an Engine instance.""" + map_width = 80 + map_height = 43 + + room_max_size = 10 + room_min_size = 6 + max_rooms = 30 + + max_monsters_per_room = 2 + max_items_per_room = 2 + + player = copy.deepcopy(entity_factories.player) + + engine = Engine(player) + + engine.game_map = generate_dungeon( + max_rooms=max_rooms, + room_min_size=room_min_size, + room_max_size=room_max_size, + map_width=map_width, + map_height=map_height, + max_monsters_per_room=max_monsters_per_room, + max_items_per_room=max_items_per_room, + engine=engine, + ) + engine.update_fov() + + engine.message_log.add_message( + "Hello and welcome, adventurer, to yet another dungeon!", + color.welcome_text + ) + + return engine + + +def load_game(filename: str) -> Engine: + """Load an engine instance from a file.""" + with open(filename, "rb") as f: + engine = pickle.loads(lzma.decompress(f.read())) + assert isinstance(engine, Engine) + + return engine + + +class MainMenu(input_handlers.BaseEventHandler): + """Handle the main menu rendering and input.""" + + def on_render(self, console: tcod.Console) -> None: + """Render the main menu on a background image.""" + console.draw_semigraphics(background_image, 0, 0) + + console.print( + console.width // 2, + console.height // 2 - 4, + "TOMBS OF THE ANCIENT KINGS", + fg=color.menu_title, + alignment=tcod.CENTER, + ) + console.print( + console.width // 2, + console.height - 2, + "By Timothy J. Warren", + fg=color.menu_title, + alignment=tcod.CENTER, + ) + + menu_width = 24 + for i, text in enumerate([ + "[N] Play a new game", + "[C] Continue last game", + "[Q] Quit" + ]): + console.print( + console.width // 2, + console.height // 2 - 2 + i, + text.ljust(menu_width), + fg=color.menu_text, + bg=color.black, + alignment=tcod.CENTER, + bg_blend=tcod.BKGND_ALPHA(64), + ) + + def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[input_handlers.BaseEventHandler]: + if event.sym in (tcod.event.K_q, tcod.event.K_ESCAPE): + raise SystemExit() + elif event.sym == tcod.event.K_c: + try: + return input_handlers.MainGameEventHandler(load_game("savegame.sav")) + except FileNotFoundError: + return input_handlers.PopupMessage(self, "No saved game to load.") + except Exception as exc: + traceback.print_exc() # Print to stderr. + return input_handlers.PopupMessage(self, f"Failed to load save:\n{exc}") + elif event.sym == tcod.event.K_n: + return input_handlers.MainGameEventHandler(new_game()) + + return None