Skip to content

Commit a22d942

Browse files
committed
Part 10: Saving and loading
1 parent 0986a72 commit a22d942

File tree

7 files changed

+213
-50
lines changed

7 files changed

+213
-50
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: 5 additions & 1 deletion
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-
red = (0xFF, 0x00, 0x00)
22+
menu_title = (255, 255, 63)
23+
menu_text = white
24+
25+
red = (0xFF, 0x00, 0x00)
26+
error = (0xFF, 0x40, 0x40)

game/engine.py

Lines changed: 8 additions & 3 deletions
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,9 +22,6 @@ def __init__(self, player: game.entity.Entity):
2022
self.player = player
2123
self.mouse_location = (0, 0)
2224
self.message_log = game.message_log.MessageLog()
23-
self.message_log.add_message(
24-
"Hello and welcome, adventurer, to yet another dungeon!", game.color.welcome_text
25-
)
2625

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

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

game/exceptions.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,8 @@ class Impossible(Exception):
22
"""Exception raised when an action is impossible to be performed.
33
44
The reason is given as the exception message.
5-
"""
5+
"""
6+
7+
8+
class QuitWithoutSaving(SystemExit):
9+
"""Can be raised to exit the game without automatically saving."""

game/input_handlers.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,9 +363,9 @@ def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
363363

364364
def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> Optional[ActionOrHandler]:
365365
"""Left click confirms a selection."""
366-
if self.engine.game_map.in_bounds(*event.tile):
366+
if self.engine.game_map.in_bounds(int(event.position.x), int(event.position.y)):
367367
if event.button == 1:
368-
return self.on_index_selected(*event.tile)
368+
return self.on_index_selected(int(event.position.x), int(event.position.y))
369369
return super().ev_mousebuttondown(event)
370370

371371
def on_index_selected(self, x: int, y: int) -> Optional[ActionOrHandler]:
@@ -427,3 +427,30 @@ def on_render(self, console: tcod.console.Console) -> None:
427427

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

game/setup_game.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Handle the loading and initialization of game sessions."""
2+
from __future__ import annotations
3+
4+
import copy
5+
import lzma
6+
import pickle
7+
import traceback
8+
from typing import Optional
9+
10+
from tcod import libtcodpy
11+
import tcod
12+
13+
import game.color
14+
import game.engine
15+
import game.entity_factories
16+
import game.game_map
17+
import game.input_handlers
18+
import game.procgen
19+
20+
21+
# Load the background image and remove the alpha channel.
22+
try:
23+
background_image = tcod.image.load("data/menu_background.png")[:, :, :3]
24+
except FileNotFoundError:
25+
# Create a placeholder background if image is missing
26+
import numpy as np
27+
background_image = np.zeros((80, 50, 3), dtype=np.uint8)
28+
29+
30+
def new_game() -> game.engine.Engine:
31+
"""Return a brand new game session as an Engine instance."""
32+
map_width = 80
33+
map_height = 43
34+
35+
room_max_size = 10
36+
room_min_size = 6
37+
max_rooms = 30
38+
39+
max_monsters_per_room = 2
40+
max_items_per_room = 2
41+
42+
player = copy.deepcopy(game.entity_factories.player)
43+
44+
engine = game.engine.Engine(player=player)
45+
46+
engine.game_map = game.procgen.generate_dungeon(
47+
max_rooms=max_rooms,
48+
room_min_size=room_min_size,
49+
room_max_size=room_max_size,
50+
map_width=map_width,
51+
map_height=map_height,
52+
max_monsters_per_room=max_monsters_per_room,
53+
max_items_per_room=max_items_per_room,
54+
engine=engine,
55+
)
56+
engine.update_fov()
57+
58+
engine.message_log.add_message(
59+
"Hello and welcome, adventurer, to yet another dungeon!", game.color.welcome_text
60+
)
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(
96+
["[N] Play a new game", "[C] Continue last game", "[Q] Quit"]
97+
):
98+
console.print(
99+
console.width // 2,
100+
console.height // 2 - 2 + i,
101+
text.ljust(menu_width),
102+
fg=game.color.menu_text,
103+
bg=game.color.black,
104+
alignment=libtcodpy.CENTER,
105+
bg_blend=libtcodpy.BKGND_ALPHA(64),
106+
)
107+
108+
def ev_keydown(
109+
self, event: tcod.event.KeyDown
110+
) -> Optional[game.input_handlers.BaseEventHandler]:
111+
if event.sym in (tcod.event.KeySym.Q, tcod.event.KeySym.ESCAPE):
112+
raise SystemExit()
113+
elif event.sym == tcod.event.KeySym.C:
114+
try:
115+
return game.input_handlers.MainGameEventHandler(load_game("savegame.sav"))
116+
except FileNotFoundError:
117+
return game.input_handlers.PopupMessage(self, "No saved game to load.")
118+
except Exception as exc:
119+
traceback.print_exc() # Print to stderr.
120+
return game.input_handlers.PopupMessage(self, f"Failed to load save:\n{exc}")
121+
elif event.sym == tcod.event.KeySym.N:
122+
return game.input_handlers.MainGameEventHandler(new_game())
123+
124+
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)