Skip to content

Commit e133967

Browse files
committed
Part 10: Saving and loading
1 parent 2ad2c39 commit e133967

File tree

7 files changed

+205
-44
lines changed

7 files changed

+205
-44
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,4 @@ Temporary Items
216216

217217
# Saved games
218218
*.sav
219+
*.sav

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,5 +1,7 @@
11
from __future__ import annotations
22

3+
import lzma
4+
import pickle
35
from typing import TYPE_CHECKING
46

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

main.py

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

44
import tcod
55

6-
import game.engine
7-
import game.entity_factories
8-
import game.game_map
6+
import game.color
7+
import game.exceptions
98
import game.input_handlers
10-
import game.procgen
9+
import game.setup_game
10+
11+
12+
def save_game(handler: game.input_handlers.BaseEventHandler, filename: str) -> None:
13+
"""If the current event handler has an active Engine then save it."""
14+
if isinstance(handler, game.input_handlers.EventHandler):
15+
handler.engine.save_as(filename)
16+
print("Game saved.")
1117

1218

1319
def main() -> None:
1420
screen_width = 80
1521
screen_height = 50
1622

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-
2623
tileset = tcod.tileset.load_tilesheet(
2724
"data/dejavu10x10_gs_tc.png", 32, 8, tcod.tileset.CHARMAP_TCOD
2825
)
2926

30-
player = copy.deepcopy(game.entity_factories.player)
31-
32-
engine = game.engine.Engine(player=player)
33-
34-
engine.game_map = game.procgen.generate_dungeon(
35-
max_rooms=max_rooms,
36-
room_min_size=room_min_size,
37-
room_max_size=room_max_size,
38-
map_width=map_width,
39-
map_height=map_height,
40-
max_monsters_per_room=max_monsters_per_room,
41-
max_items_per_room=max_items_per_room,
42-
engine=engine,
43-
)
44-
engine.update_fov()
45-
46-
# Part 10 refactoring: Track handler in main loop
47-
handler: game.input_handlers.BaseEventHandler = game.input_handlers.MainGameEventHandler(engine)
27+
handler: game.input_handlers.BaseEventHandler = game.setup_game.MainMenu()
4828

4929
with tcod.context.new(
5030
columns=screen_width,
@@ -54,17 +34,35 @@ def main() -> None:
5434
vsync=True,
5535
) as context:
5636
root_console = tcod.console.Console(screen_width, screen_height, order="F")
57-
while True:
58-
root_console.clear()
59-
handler.on_render(console=root_console)
60-
context.present(root_console)
61-
62-
# Part 10 refactoring: Handler manages its own state transitions
63-
for event in tcod.event.wait():
64-
# libtcodpy deprecation: convert mouse events
65-
if isinstance(event, tcod.event.MouseMotion):
66-
event = context.convert_event(event)
67-
handler = handler.handle_events(event)
37+
try:
38+
while True:
39+
root_console.clear()
40+
handler.on_render(console=root_console)
41+
context.present(root_console)
42+
43+
try:
44+
for event in tcod.event.wait():
45+
# libtcodpy deprecation: convert mouse events
46+
if isinstance(event, tcod.event.MouseMotion) or \
47+
isinstance(event, tcod.event.MouseButtonUp) or \
48+
isinstance(event, tcod.event.MouseButtonDown):
49+
event = context.convert_event(event)
50+
handler = handler.handle_events(event)
51+
except Exception: # Handle exceptions in game.
52+
traceback.print_exc() # Print error to stderr.
53+
# Then print the error to the message log.
54+
if isinstance(handler, game.input_handlers.EventHandler):
55+
handler.engine.message_log.add_message(
56+
traceback.format_exc(), game.color.error
57+
)
58+
except game.exceptions.QuitWithoutSaving:
59+
raise
60+
except SystemExit: # Save and quit.
61+
save_game(handler, "savegame.sav")
62+
raise
63+
except BaseException: # Save on any other unexpected exception.
64+
save_game(handler, "savegame.sav")
65+
raise
6866

6967

7068
if __name__ == "__main__":

0 commit comments

Comments
 (0)