Part 10 - Saving and Loading
In this part we’ll make it possible to save and load games, and add a main menu.
This part is loosely based on this part of the python tcod tutorial.
Reference implementation branch for starting point: part-9-end
In this post:
Serializing Game State
In order to save the game, we must describe a way to convert the GameState
type to and from a sequence of bytes which can be stored in a file.
In rust, the typical way to do this is using a crate called serde.
It defines traits Serialize
and Deserialize
, which can be derived on a type
just like Clone
, Copy
, Debug
, etc.
Many of the crates our game depend on can be configured to use serde to make the types they define
implement Serialize
and Deserilaize
. The GameState
type contains many types imported from
crates. The first step is to configure these crates to allow the types they define to be serialized.
Update the [dependencies]
section of Cargo.toml
to look like this:
# Cargo.toml
...
[dependencies]
chargrid_graphical = "0.7"
chargrid = { version = "0.4", features = ["serialize"] }
coord_2d = { version = "0.3", features = ["serialize"] }
grid_2d = { version = "0.15", features = ["serialize"] }
rgb24 = { version = "0.3", features = ["serialize"] }
direction = { version = "0.18", features = ["rand", "serialize"] }
entity_table = { version = "0.2", features = ["serialize"] }
spatial_table = { version = "0.3", features = ["serialize"] }
rand = "0.8"
rand_isaac = { version = "0.3", features = ["serde1"] }
shadowcast = { version = "0.8", features = ["serialize"] }
meap = "0.4"
grid_search_cardinal = { version = "0.3", features = ["serialize"] }
line_2d = { version = "0.5", features = ["serialize"] }
serde = { version = "1.0", features = ["serde_derive"] }
Note the addition of the serde
crate.
Most existing crates have had a feature enabled which turn on serialization.
Now in game.rs
, import the Serialize
and Deserialize
traits from serde
:
// game.rs
...
use serde::{Deserialize, Serialize};
...
…and derive them for the GameState
type:
#[derive(Serialize, Deserialize)]
pub struct GameState {
...
}
The derived implementation of (de)serialization will invoke the (de)serialization methods for each of its fields. Some of its fields won’t have (de)serialization methods yet, so you’ll see many errors of the form:
the trait `serde::Deserialize<'_>` is not implemented for <type>
where <type>
is a type defined in the game’s code.
For each type that produces this error, derive the Serialize
and Deserialize
traits.
Reference implementation branch: part-10.0
Main Menu
Now let’s add a main menu.
Start by defining the main menu entry, view, select, and decorator types, and a function returning an EventRoutine
, much as we did
for the inventory menu:
// app.rs
...
#[derive(Clone, Copy, Debug)]
enum MainMenuEntry {
NewGame,
Resume,
Quit,
}
fn main_menu_instance() -> MenuInstanceChooseOrEscape<MainMenuEntry> {
use MainMenuEntry::*;
MenuInstanceBuilder {
items: vec![Resume, NewGame, Quit],
hotkeys: Some(hashmap!['r' => Resume, 'n' => NewGame, 'q' => Quit]),
selected_index: 0,
}
.build()
.unwrap()
.into_choose_or_escape()
}
#[derive(Default)]
struct MainMenuView {
mouse_tracker: MenuInstanceMouseTracker,
}
impl MenuIndexFromScreenCoord for MainMenuView {
fn menu_index_from_screen_coord(&self, len: usize, coord: Coord) -> Option<usize> {
self.mouse_tracker.menu_index_from_screen_coord(len, coord)
}
}
impl<'a> View<&'a AppData> for MainMenuView {
fn view<F: Frame, C: ColModify>(
&mut self,
data: &'a AppData,
context: ViewContext<C>,
frame: &mut F,
) {
self.mouse_tracker.new_frame(context.offset);
for (i, &entry, maybe_selected) in data.main_menu.menu_instance().enumerate() {
let (prefix, style) = if maybe_selected.is_some() {
(
">",
Style::new()
.with_foreground(Rgb24::new_grey(255))
.with_bold(true),
)
} else {
(" ", Style::new().with_foreground(Rgb24::new_grey(187)))
};
let text = match entry {
MainMenuEntry::Resume => "(r) Resume",
MainMenuEntry::NewGame => "(n) New Game",
MainMenuEntry::Quit => "(q) Quit",
};
let size = StringViewSingleLine::new(style).view_size(
format!("{} {}", prefix, text),
context.add_offset(Coord::new(0, i as i32)),
frame,
);
self.mouse_tracker.on_entry_view_size(size);
}
}
}
struct MainMenuSelect;
impl ChooseSelector for MainMenuSelect {
type ChooseOutput = MenuInstanceChooseOrEscape<MainMenuEntry>;
fn choose_mut<'a>(&self, input: &'a mut Self::DataInput) -> &'a mut Self::ChooseOutput {
&mut input.main_menu
}
}
impl DataSelector for MainMenuSelect {
type DataInput = AppData;
type DataOutput = AppData;
fn data<'a>(&self, input: &'a Self::DataInput) -> &'a Self::DataOutput {
input
}
fn data_mut<'a>(&self, input: &'a mut Self::DataInput) -> &'a mut Self::DataOutput {
input
}
}
impl ViewSelector for MainMenuSelect {
type ViewInput = AppView;
type ViewOutput = MainMenuView;
fn view<'a>(&self, input: &'a Self::ViewInput) -> &'a Self::ViewOutput {
&input.main_menu_view
}
fn view_mut<'a>(&self, input: &'a mut Self::ViewInput) -> &'a mut Self::ViewOutput {
&mut input.main_menu_view
}
}
struct MainMenuDecorate;
impl Decorate for MainMenuDecorate {
type View = AppView;
type Data = AppData;
fn view<E, F, C>(
&self,
data: &Self::Data,
mut event_routine_view: EventRoutineView<E>,
context: ViewContext<C>,
frame: &mut F,
) where
E: EventRoutine<Data = Self::Data, View = Self::View>,
F: Frame,
C: ColModify,
{
BoundView {
size: data.game_state.size(),
view: AlignView {
alignment: Alignment::centre(),
view: FillBackgroundView {
rgb24: Rgb24::new_grey(0),
view: BorderView {
style: &BorderStyle {
title: None,
title_style: Style::new().with_foreground(Rgb24::new_grey(255)),
..Default::default()
},
view: MinSizeView {
size: Size::new(12, 0),
view: &mut event_routine_view,
},
},
},
},
}
.view(data, context.add_depth(10), frame);
event_routine_view.view.game_view.view(
&data.game_state,
context.compose_col_modify(ColModifyMap(|c: Rgb24| c.saturating_scalar_mul_div(1, 2))),
frame,
);
event_routine_view
.view
.render_ui(None, &data, context, frame);
}
}
fn main_menu() -> impl EventRoutine<
Return = Result<MainMenuEntry, menu::Escape>,
Data = AppData,
View = AppView,
Event = CommonEvent,
> {
MenuInstanceRoutine::new(MainMenuSelect)
.convert_input_to_common_event()
.decorated(MainMenuDecorate)
}
Note the hashmap!
macro used to specify hotkeys for the main menu.
This is from a crate called maplit
, which needs to be imported.
# Cargo.toml
...
[dependencies]
maplit = "1.0"
// app.rs
...
use maplit::hashmap;
...
Add the relevant main menu types to AppData
and AppView
:
...
struct AppData {
...
main_menu: MenuInstanceChooseOrEscape<MainMenuEntry>,
}
impl Data {
fn new(screen_size: Size, rng_seed: u64, visibility_algorithm: VisibilityAlgorithm) -> Self {
...
Self {
...
main_menu: main_menu_instance(),
}
}
...
}
struct AppView {
...
main_menu_view: MainMenuView,
}
impl AppView {
fn new(screen_size: Size) -> Self {
...
Self {
...
main_menu_view: MainMenuView::default(),
}
}
...
}
...
At the moment, when the escape key is pressed, the game exits. Let’s change it so that the
menu opens instead. There’s no longer a need for the GameReturn::Exit
variant, so remove it.
enum GameReturn {
Menu,
...
}
...
impl AppData {
...
fn handle_input(&mut self, input: Input) -> Option<GameReturn> {
match input {
Input::Keyboard(key) => {
match key {
...
keys::ESCAPE => return Some(GameReturn::Menu),
...
}
...
}
...
}
...
}
}
Handle the GameReturn::Menu
value in game_loop
. Have it run the main_menu()
event routine
and handle the choice from that menu.
...
fn game_loop() -> impl EventRoutine<Return = (), Data = AppData, View = AppView, Event = CommonEvent>
{
make_either!(Ei = A | B | C | D | E);
Loop::new(|| {
GameEventRoutine.and_then(|game_return| match game_return {
GameReturn::Menu => Ei::A(main_menu().and_then(|choice| {
make_either!(Ei = A | B);
match choice {
Err(menu::Escape) => Ei::A(Value::new(None)),
Ok(MainMenuEntry::Resume) => Ei::A(Value::new(None)),
Ok(MainMenuEntry::Quit) => Ei::A(Value::new(Some(()))),
Ok(MainMenuEntry::NewGame) => {
Ei::B(SideEffect::new_with_view(|data: &mut AppData, _: &_| {
data.new_game();
None
}))
}
}
})),
GameReturn::GameOver => Ei::B(game_over().map(|()| Some(()))),
GameReturn::UseItem => Ei::C(use_item().map(|_| None)),
GameReturn::DropItem => Ei::D(drop_item().map(|_| None)),
GameReturn::Examine => Ei::E(TargetEventRoutine { name: "EXAMINE" }.map(|_| None)),
})
})
.return_on_exit(|_| ())
}
...
In the NewGame
case, we’re calling a .new_game()
method of AppData
which we’ve yet to implement.
Implement this now. This will require adding some fields to AppData
.
...
struct AppData {
...
game_area_size: Size,
rng_seed: u64,
}
impl AppData {
fn new(screen_size: Size, rng_seed: u64, visibility_algorithm: VisibilityAlgorithm) -> Self {
let game_area_size = screen_size.set_height(screen_size.height() - UI_NUM_ROWS);
...
Self {
...
game_area_size,
rng_seed,
}
}
fn new_game(&mut self) {
self.rng_seed = self.rng_seed.wrapping_add(1);
self.game_state = GameState::new(
self.game_area_size,
self.rng_seed,
self.visibility_algorithm,
);
}
...
}
...
The rng seed is incremented so each time a new game is started, its random number generator is
in a different state, and the level will be generated differently.
Since the rng seed is changing mid-game, rather than being set once at startup, move
the code that prints the rng seed from main
to GameState::new
, so if you observe
an error after hitting New Game
several times, it’s still possible to easily reproduce it.
Now that we have the ability to start a new game, change game_loop
again so that when the player dies,
a new game is started.
...
fn game_loop() -> impl EventRoutine<Return = (), Data = AppData, View = AppView, Event = CommonEvent>
{
make_either!(Ei = A | B | C | D | E);
Loop::new(|| {
GameEventRoutine.and_then(|game_return| match game_return {
...
GameReturn::GameOver => Ei::B(game_over().and_then(|()| {
SideEffect::new_with_view(|data: &mut AppData, _: &_| {
data.new_game();
None
})
})),
...
})
})
.return_on_exit(|_| ())
}
...
Reference implementation branch: part-10.1
Saving
Add the general_storage_file
crate which will help with storing and retrieving serialized state in a file.
The goal of this crate is to present an abstract view of persistent data, backed by files in a directory.
# Cargo.toml
...
[dependencies]
general_storage_file = { version = "0.1", features = ["json", "compress"] }
Note the json
and compress
features. This crate lets you choose between a number of different data serialization
formats, but all are disabled by default and require explicit features to enable. This is because each format
depends on additional crates. We reduce the transitive dependencies of our game by only adding storage
formats which we need.
Now in app.rs
, start using the crate, and define some constants that will configure how we use the crate.
// app.rs
...
use general_storage_file::{format, FileStorage, IfDirectoryMissing, Storage};
...
const SAVE_DIR: &str = "save";
const SAVE_FILE: &str = "save";
const SAVE_FORMAT: format::Compress<format::Json> = format::Compress(format::Json);
SAVE_DIR
is the directory in which the save file will be placed. SAVE_FILE
is the name of the file
which will contain the save game. SAVE_FORMAT
defines how the game’s state will be serialized.
format::Compress(format::Json)
means create a json string representing the game’s state, then
compress that (with gzip). An alternative format, format::Bincode
is available with the bincode
feature flag, which serializes with the bincode
crate. It’s not used here, as it causes programs to crash if the type definitions change between
serializing and deserializing data (which will happen here as we’re constantly adding to this game!).
In contrast, the json serializer just returns an error in this situation. Switch to bincode once the
game is finished.
Replace MainMenu::Quit
with MainMenu::SaveAndQuit
.
...
enum MainMenuEntry {
...
SaveAndQuit,
}
...
fn main_menu_instance() -> MenuInstanceChooseOrEscape<MainMenuEntry> {
use MainMenuEntry::*;
MenuInstanceBuilder {
items: vec![Resume, NewGame, SaveAndQuit],
hotkeys: Some(hashmap!['r' => Resume, 'n' => NewGame, 'q' => SaveAndQuit]),
selected_index: 0,
}
.build()
.unwrap()
.into_choose_or_escape()
}
...
impl<'a> View<&'a AppData> for MainMenuView {
fn view<F: Frame, C: ColModify>(
&mut self,
data: &'a AppData,
context: ViewContext<C>,
frame: &mut F,
) {
self.mouse_tracker.new_frame(context.offset);
for (i, &entry, maybe_selected) in data.main_menu.menu_instance().enumerate() {
...
let text = match entry {
MainMenuEntry::Resume => "(r) Resume",
MainMenuEntry::NewGame => "(n) New Game",
MainMenuEntry::SaveAndQuit => "(q) Save and Quit",
};
...
}
}
}
...
fn game_loop() -> impl EventRoutine<Return = (), Data = AppData, View = AppView, Event = CommonEvent>
{
make_either!(Ei = A | B | C | D | E);
Loop::new(|| {
GameEventRoutine.and_then(|game_return| match game_return {
GameReturn::Menu => Ei::A(main_menu().and_then(|choice| {
make_either!(Ei = A | B | C);
match choice {
...
Ok(MainMenuEntry::SaveAndQuit) => {
Ei::C(SideEffect::new_with_view(|data: &mut AppData, _: &_| {
Some(())
}))
}
...
}
})),
...
})
})
.return_on_exit(|data| data.save_game())
}
...
Implement a method for saving the game state to a file.
...
impl AppData {
...
fn save_game(&self) {
let mut file_storage = match FileStorage::next_to_exe(SAVE_DIR, IfDirectoryMissing::Create)
{
Ok(file_storage) => file_storage,
Err(error) => {
eprintln!("Failed to save game: {:?}", error);
return;
}
};
println!("Saving to {:?}", file_storage.full_path(SAVE_FILE));
match file_storage.store(SAVE_FILE, &self.game_state, SAVE_FORMAT) {
Ok(()) => (),
Err(error) => {
eprintln!("Failed to save game: {:?}", error);
return;
}
}
}
...
}
...
It creates a directory called “save” next to the game’s executable, and serializes the game’s state into a file in this directory, also called “save”.
Now call this method from game_loop
, both when SaveAndQuit
is selected from the main menu, and when
the game is closed.
...
fn game_loop() -> impl EventRoutine<Return = (), Data = AppData, View = AppView, Event = CommonEvent>
{
make_either!(Ei = A | B | C | D | E);
Loop::new(|| {
GameEventRoutine.and_then(|game_return| match game_return {
GameReturn::Menu => Ei::A(main_menu().and_then(|choice| {
make_either!(Ei = A | B | C);
match choice {
...
Ok(MainMenuEntry::SaveAndQuit) => {
Ei::C(SideEffect::new_with_view(|data: &mut AppData, _: &_| {
data.save_game();
Some(())
}))
}
...
}
})),
...
})
})
.return_on_exit(|data| data.save_game())
}
...
Reference implementation branch: part-10.2
Loading
Define a method in AppData
which attempts to deserialize a game state from a file.
// app.rs
...
impl AppData {
...
fn load_game() -> Option<GameState> {
let file_storage = match FileStorage::next_to_exe(SAVE_DIR, IfDirectoryMissing::Create) {
Ok(file_storage) => file_storage,
Err(error) => {
eprintln!("Failed to load game: {:?}", error);
return None;
}
};
if !file_storage.exists(SAVE_FILE) {
return None;
}
println!("Loading from {:?}", file_storage.full_path(SAVE_FILE));
match file_storage.load(SAVE_FILE, SAVE_FORMAT) {
Ok(game_state) => Some(game_state),
Err(error) => {
eprintln!("Failed to load game: {:?}", error);
None
}
}
}
...
}
...
If it fails to deserialize the state, it prints a warning and continues. This will likely happen from time to time, since the (de)serialization logic is derived from the structure of the types used in the game. Whenever we change the definition of a type, the game is no longer able to understand the serialized representation of the old versions of these types.
Call load_game
when creating a new AppData
.
...
impl AppData {
fn new(screen_size: Size, rng_seed: u64, visibility_algorithm: VisibilityAlgorithm) -> Self {
...
let game_state = Self::load_game()
.unwrap_or_else(|| GameState::new(game_area_size, rng_seed, visibility_algorithm));
...
}
}
...
Reference implementation branch: part-10.3