Skip to content

Commit 5d8c7ed

Browse files
committed
Part 10: Saving and loading
1 parent 45dbe1d commit 5d8c7ed

File tree

6 files changed

+191
-35
lines changed

6 files changed

+191
-35
lines changed

game/color.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,8 @@
1919
bar_filled = (0x0, 0x60, 0x0)
2020
bar_empty = (0x40, 0x10, 0x10)
2121

22+
menu_title = (255, 255, 63)
23+
menu_text = white
24+
2225
red = (0xFF, 0x00, 0x00)
26+
error = (0xFF, 0x40, 0x40)

game/engine.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
from typing import TYPE_CHECKING
4+
import lzma
5+
import pickle
46

57
import tcod
68

@@ -20,7 +22,6 @@ def __init__(self, player: Actor):
2022
self.player = player
2123
self.mouse_location = (0, 0)
2224
self.message_log = MessageLog()
23-
self.message_log.add_message("Hello and welcome, adventurer, to yet another dungeon!", welcome_text)
2425

2526
def update_fov(self) -> None:
2627
"""Recompute the visible area based on the players point of view."""
@@ -52,3 +53,9 @@ def render(self, console: tcod.console.Console) -> None:
5253
)
5354

5455
render_names_at_mouse_location(console=console, x=21, y=44, engine=self)
56+
57+
def save_as(self, filename: str) -> None:
58+
"""Save this Engine instance as a compressed file."""
59+
save_data = lzma.compress(pickle.dumps(self))
60+
with open(filename, "wb") as f:
61+
f.write(save_data)

game/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@ class Impossible(Exception):
33
44
The reason is given as the exception message.
55
"""
6+
7+
8+
class QuitWithoutSaving(SystemExit):
9+
"""Can be raised to exit the game without automatically saving."""

game/input_handlers.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,3 +430,30 @@ def on_render(self, console: tcod.console.Console) -> None:
430430

431431
def on_index_selected(self, x: int, y: int) -> Optional[game.actions.Action]:
432432
return self.callback((x, y))
433+
434+
435+
class PopupMessage(BaseEventHandler):
436+
"""Display a popup text window."""
437+
438+
def __init__(self, parent_handler: BaseEventHandler, text: str):
439+
self.parent = parent_handler
440+
self.text = text
441+
442+
def on_render(self, console: tcod.console.Console) -> None:
443+
"""Render the parent and dim the result, then print the message on top."""
444+
self.parent.on_render(console)
445+
console.rgb["fg"] //= 8
446+
console.rgb["bg"] //= 8
447+
448+
console.print(
449+
console.width // 2,
450+
console.height // 2,
451+
self.text,
452+
fg=game.color.white,
453+
bg=game.color.black,
454+
alignment=tcod.CENTER,
455+
)
456+
457+
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseEventHandler]:
458+
"""Any key returns to the parent handler."""
459+
return self.parent

game/setup_game.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Handle the loading and initialization of game sessions."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Optional
6+
import copy
7+
import lzma
8+
import pickle
9+
import traceback
10+
11+
from PIL import Image
12+
from tcod import libtcodpy
13+
import numpy as np
14+
import tcod
15+
16+
from game.color import black, menu_text, menu_title, welcome_text
17+
from game.engine import Engine
18+
from game.input_handlers import BaseEventHandler, MainGameEventHandler, PopupMessage
19+
from game.procgen import generate_dungeon
20+
import game.entity_factories
21+
import game.game_map
22+
23+
# Load the background image and remove the alpha channel.
24+
background_image = np.array(Image.open("data/menu_background.png").convert("RGB"))
25+
26+
27+
def new_game() -> Engine:
28+
"""Return a brand new game session as an Engine instance."""
29+
map_width = 80
30+
map_height = 43
31+
32+
room_max_size = 10
33+
room_min_size = 6
34+
max_rooms = 30
35+
36+
max_monsters_per_room = 2
37+
max_items_per_room = 2
38+
39+
player = copy.deepcopy(game.entity_factories.player)
40+
41+
engine = Engine(player=player)
42+
43+
engine.game_map = generate_dungeon(
44+
max_rooms=max_rooms,
45+
room_min_size=room_min_size,
46+
room_max_size=room_max_size,
47+
map_width=map_width,
48+
map_height=map_height,
49+
max_monsters_per_room=max_monsters_per_room,
50+
max_items_per_room=max_items_per_room,
51+
engine=engine,
52+
)
53+
engine.update_fov()
54+
55+
engine.message_log.add_message("Hello and welcome, adventurer, to yet another dungeon!", welcome_text)
56+
return engine
57+
58+
59+
def load_game(filename: str) -> Engine:
60+
"""Load an Engine instance from a file."""
61+
with open(filename, "rb") as f:
62+
engine = pickle.loads(lzma.decompress(f.read()))
63+
assert isinstance(engine, Engine)
64+
return engine
65+
66+
67+
class MainMenu(BaseEventHandler):
68+
"""Handle the main menu rendering and input."""
69+
70+
def on_render(self, console: tcod.console.Console) -> None:
71+
"""Render the main menu on a background image."""
72+
console.draw_semigraphics(background_image, 0, 0)
73+
74+
console.print(
75+
console.width // 2,
76+
console.height // 2 - 4,
77+
"TOMBS OF THE ANCIENT KINGS",
78+
fg=menu_title,
79+
alignment=libtcodpy.CENTER,
80+
)
81+
console.print(
82+
console.width // 2,
83+
console.height - 2,
84+
"By (Your name here)",
85+
fg=menu_title,
86+
alignment=libtcodpy.CENTER,
87+
)
88+
89+
menu_width = 24
90+
for i, text in enumerate(["[N] Play a new game", "[C] Continue last game", "[Q] Quit"]):
91+
console.print(
92+
console.width // 2,
93+
console.height // 2 - 2 + i,
94+
text.ljust(menu_width),
95+
fg=menu_text,
96+
bg=black,
97+
alignment=libtcodpy.CENTER,
98+
bg_blend=libtcodpy.BKGND_ALPHA(64),
99+
)
100+
101+
def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseEventHandler]:
102+
if event.sym in (tcod.event.KeySym.Q, tcod.event.KeySym.ESCAPE):
103+
raise SystemExit()
104+
elif event.sym == tcod.event.KeySym.C:
105+
try:
106+
return MainGameEventHandler(load_game("savegame.sav"))
107+
except FileNotFoundError:
108+
return PopupMessage(self, "No saved game to load.")
109+
except Exception as exc:
110+
traceback.print_exc() # Print to stderr.
111+
return PopupMessage(self, f"Failed to load save:\n{exc}")
112+
elif event.sym == tcod.event.KeySym.N:
113+
return MainGameEventHandler(new_game())
114+
115+
return None

main.py

Lines changed: 33 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,31 @@
11
#!/usr/bin/env python3
2-
import copy
2+
import traceback
33

44
import tcod
55

66
from game.engine import Engine
77
from game.entity import Entity
8+
from game.exceptions import QuitWithoutSaving
89
from game.input_handlers import BaseEventHandler, MainGameEventHandler
910
from game.procgen import generate_dungeon
11+
from game.setup_game import MainMenu
1012
import game.entity_factories
1113

1214

15+
def save_game(handler: game.input_handlers.BaseEventHandler, filename: str) -> None:
16+
"""If the current event handler has an active Engine then save it."""
17+
if isinstance(handler, game.input_handlers.EventHandler):
18+
handler.engine.save_as(filename)
19+
print("Game saved.")
20+
21+
1322
def main() -> None:
1423
screen_width = 80
1524
screen_height = 50
1625

17-
map_width = 80
18-
map_height = 45
19-
20-
room_max_size = 10
21-
room_min_size = 6
22-
max_rooms = 30
23-
max_monsters_per_room = 2
24-
max_items_per_room = 2
25-
2626
tileset = tcod.tileset.load_tilesheet("data/dejavu10x10_gs_tc.png", 32, 8, tcod.tileset.CHARMAP_TCOD)
2727

28-
player = copy.deepcopy(game.entity_factories.player)
29-
30-
engine = Engine(player=player)
31-
32-
engine.game_map = generate_dungeon(
33-
max_rooms=max_rooms,
34-
room_min_size=room_min_size,
35-
room_max_size=room_max_size,
36-
map_width=map_width,
37-
map_height=map_height,
38-
max_monsters_per_room=max_monsters_per_room,
39-
max_items_per_room=max_items_per_room,
40-
engine=engine,
41-
)
42-
engine.update_fov()
43-
44-
handler: BaseEventHandler = MainGameEventHandler(engine)
28+
handler: game.input_handlers.BaseEventHandler = MainMenu()
4529

4630
with tcod.context.new(
4731
columns=screen_width,
@@ -51,14 +35,29 @@ def main() -> None:
5135
vsync=True,
5236
) as context:
5337
root_console = tcod.console.Console(screen_width, screen_height, order="F")
54-
while True:
55-
root_console.clear()
56-
handler.on_render(console=root_console)
57-
context.present(root_console)
38+
try:
39+
while True:
40+
root_console.clear()
41+
handler.on_render(console=root_console)
42+
context.present(root_console)
5843

59-
for event in tcod.event.wait():
60-
event = context.convert_event(event)
61-
handler = handler.handle_events(event)
44+
try:
45+
for event in tcod.event.wait():
46+
event = context.convert_event(event)
47+
handler = handler.handle_events(event)
48+
except Exception: # Handle exceptions in game.
49+
traceback.print_exc() # Print error to stderr.
50+
# Then print the error to the message log.
51+
if isinstance(handler, game.input_handlers.EventHandler):
52+
handler.engine.message_log.add_message(traceback.format_exc(), game.color.error)
53+
except QuitWithoutSaving:
54+
raise
55+
except SystemExit: # Save and quit.
56+
save_game(handler, "savegame.sav")
57+
raise
58+
except BaseException: # Save on any other unexpected exception.
59+
save_game(handler, "savegame.sav")
60+
raise
6261

6362

6463
if __name__ == "__main__":

0 commit comments

Comments
 (0)