Now that our game is bursting with gameplay potential, we can think about those little things that die-hard fans will surely miss during their long playing sessions. One of the most important is, of course, a save/load mechanism! This way they can go to sleep and dream about your scary monsters between play sessions.
Tidy initialization
To choose between continuing a previous game or starting a new one we need a main menu. But wait: our initialization logic and game loop are tightly bound, so they’re not really prepared for these tasks. To avoid code duplication, we need to break them down into meaningful blocks (functions). We can then put them together to change between the menu and the game, start new games or load them, and even go to new dungeon levels. It’s much easier than it sounds, so fear not!
Take a look at your initialization and game loop code, after all the functions. I can identify 4 blocks:
-
System initialization (initialising the tcod window and consoles)
-
Setting up a new game (everything else except for the game loop and the FOV map creation)
-
Creating the FOV map
-
Starting the game loop
We can use these as the building blocks to make up the higher-level tasks like loading the game or moving to a new level:
-
Create a new game: set up the game, create FOV map, start game loop (this is what we have now).
-
Load game: load data (we won’t deal with this block just yet), create FOV map, start game loop.
-
Advance level: set up new level (we won’t deal with this yet either), create FOV map (the game loop is already running and will just continue).
Let’s put everything from main
except for the system initialisation
and the main loop into a new_game
function:
fn new_game(tcod: &mut Tcod) → (Game, Vec<Object>) {
// create object representing the player
let mut player = Object::new(0, 0, '@', "player", WHITE, true);
player.alive = true;
player.fighter = Some(Fighter {
max_hp: 30,
hp: 30,
defense: 2,
power: 5,
on_death: DeathCallback::Player, // <1>
});
// the list of objects with just the player
let mut objects = vec![player];
let mut game = Game {
// generate map (at this point it's not drawn to the screen)
map: make_map(&mut objects),
messages: Messages::new(),
inventory: vec![], // <1>
};
initialise_fov(tcod, &game.map);
// a warm welcoming message!
game.messages.add(
"Welcome stranger! Prepare to perish in the Tombs of the Ancient Kings.",
RED,
);
(game, objects)
}
We return a tuple with two elements: the vec of Objects
and
the Game
struct.
new_game
is calling initialise_fov
so we need to create it and move
the FOV-related code to it:
fn initialise_fov(tcod: &mut Tcod, map: &Map) {
// create the FOV map, according to the generated map
for y in 0..MAP_HEIGHT {
for x in 0..MAP_WIDTH {
tcod.fov.set(
x,
y,
!map[x as usize][y as usize].block_sight,
!map[x as usize][y as usize].blocked,
);
}
}
}
Finally, the game loop and the few bits before it belong to their own function as well:
fn play_game(tcod: &mut Tcod, game: &mut Game, objects: &mut Vec<Object>) {
// force FOV "recompute" first time through the game loop
let mut previous_player_position = (-1, -1);
while !tcod.root.window_closed() {
// clear the screen of the previous frame
tcod.con.clear();
match input::check_for_event(input::MOUSE | input::KEY_PRESS) {
Some, Event::Mouse(m) ⇒ tcod.mouse = m,
Some, Event::Key(k) ⇒ tcod.key = k,
_ ⇒ tcod.key = Default::default(),
}
// render the screen
let fov_recompute = previous_player_position != (objects[PLAYER].pos()); // <1>
render_all(tcod, game, &objects, fov_recompute);
tcod.root.flush();
// handle keys and exit game if needed
previous_player_position = objects[PLAYER].pos();
let player_action = handle_keys(tcod, game, objects);
if player_action == PlayerAction::Exit {
break;
}
// let monsters take their turn
if objects[PLAYER].alive && player_action != PlayerAction::DidntTakeTurn {
for id in 0..objects.len() {
if objects[id].ai.is_some() {
ai_take_turn(id, tcod, game, objects);
}
}
}
}
}
And now we just call new_game
and play_game
from our slimmed-down
main
function:
fn main() {
tcod::system::set_fps(LIMIT_FPS);
let root = Root::initializer()
.font("arial10x10.png", FontLayout::Tcod)
.font_type(FontType::Greyscale)
.size(SCREEN_WIDTH, SCREEN_HEIGHT)
.title("Rust/libtcod tutorial")
.init();
let mut tcod = Tcod {
root,
con: Offscreen::new(MAP_WIDTH, MAP_HEIGHT),
panel: Offscreen::new(SCREEN_WIDTH, PANEL_HEIGHT),
fov: FovMap::new(MAP_WIDTH, MAP_HEIGHT),
key: Default::default(),
mouse: Default::default(),
};
let (mut game, mut objects) = new_game(&mut tcod);
play_game(&mut tcod, &mut game, &mut objects);
}
let (a, b) = some_tuple
is how we turn a tuple into its parts. We
have to put mut
in front of each one so Rust lets us change them
later.
You can think of let (mut a, mut b)
as two separate bindings: let
mut a = …
and let mut b = …
. Except since new_game
returns a
tuple, we can’t really have them as separate.
Anyway, the game should compile again and the setup code is more modular. Which will come in handy in the coming sections.
The main menu
To keep our main menu from appearing a bit bland, it would be pretty cool to show a neat background image below it. Fortunately, tcod lets us load and display images!
Since libtcod emulates a console, we can’t directly show arbitrary images, since we can’t access the console’s pixels. We can, however, modify the background color of every console cell to match the color of a pixel from the image. The downside is that the image will be in a very low resolution.
However, libtcod can do a neat trick: by using specialized characters, and modifying both foreground and background colors, we can double the resolution! This is called subcell resolution, and this page of the docs shows some images of the effect (at the end of the page).
This means that, for our 80x50 cells console, we need a 160x100 pixels image. We’ll be using the image from the original Python tutorial.
fn main_menu(tcod: &mut Tcod) {
let img = tcod::image::Image::from_file("menu_background.png") (1)
.ok()
.expect("Background image not found"); (2)
while !tcod.root.window_closed() { (3)
// show the background image, at twice the regular console resolution
tcod::image::blit_2x(&img, (0, 0), (-1, -1), &mut tcod.root, (0, 0));
// show options and wait for the player's choice
let choices = &["Play a new game", "Continue last game", "Quit"];
let choice = menu("", choices, 24, &mut tcod.root);
match choice { (4)
Some(0) => {
// new game
let (mut game, mut objects) = new_game(tcod);
play_game(tcod, &mut game, &mut objects);
}
Some(2) => {
// quit
break;
}
_ => {} (5)
}
}
}
1 | Load the background image |
2 | Exit if the loading failed |
3 | Show the main menu in a loop — this lets us play another game after the current one ends |
4 | Either start a new game or quit |
5 | If the player selects anything else, keep showing the menu |
Now replace the calls to new_game
and play_game
in main
with:
main_menu(&mut tcod);
If you try it out now, you’ll see a nice menu with a dungeon-y backdrop!
Now let’s add the game’s title and some credits. You’ll probably want
to modify the values. Put this in the main_menu
before calling the
menu
function:
tcod.root.set_default_foreground(LIGHT_YELLOW);
tcod.root.print_ex(
SCREEN_WIDTH / 2,
SCREEN_HEIGHT / 2 - 4,
BackgroundFlag::None,
TextAlignment::Center,
"TOMBS OF THE ANCIENT KINGS",
);
tcod.root.print_ex(
SCREEN_WIDTH / 2,
SCREEN_HEIGHT - 2,
BackgroundFlag::None,
TextAlignment::Center,
"By Yours Truly",
);
You’ll notice that the menu rectangle starts with a blank line. That
is because the header string is empty, but root.get_height_rect
reports its height as 1
by default.
To make the line go away, we need to check that condition in the
menu
function:
// calculate total height for the header (after auto-wrap) and one line per option
let header_height = if header.is_empty() {
0
} else {
root.get_height_rect(0, 0, width, SCREEN_HEIGHT, header)
};
let height = options.len() as i32 + header_height;
Finally, when you start a game, go back to the main menu with Escape and start another game results in a bug! Parts of the first game are still visible in the second game. To fix that, we need to clear the console.
At the end of initialise_fov
:
// unexplored areas start black (which is the default background color)
tcod.con.clear();
There it is, a neat main menu, and with only a handful of lines of code!
Saving and loading
Storing a game state to disk (and then reloading it) is not
conceptually hard: You could imagine just taking all the data from our
game
and objects
variables and writing them to a file value by
value.
It would, however, be a huge hassle that would require a ton of code, you’d need to define a way to structure the data in the file and there’s a good chance you’d get a lot of bugs at first.
Luckily, there are ways of automating most of this that make saving and loading quite painless. Here’s the teaser:
fn save_game(game: &Game, objects: &[Object]) -> Result<(), Box<dyn Error>> { (1)
let save_data = serde_json::to_string(&(game, objects))?; (2)
let mut file = File::create("savegame")?; (3)
file.write_all(save_data.as_bytes())?; (4)
Ok(()) (5)
}
1 | save game and objects — they contain all our game state. The saving can fail so return a Result which could be Ok or Err |
2 | convert both objects and game into json |
3 | create a file called "savegame" — that’s where we’ll write the game state |
4 | write the json-ified game state to the file |
5 | if nothing went wrong return Ok , indicating success |
Don’t mind the ?
operator at the end of the line for now, it’s there for error handling and
we’ll explain it in a bit.
The first line (serde_json::to_string(&some_data)
) takes the data we
want to save (objects and the game state in our case) and turns it to
a JSON-encoded String.
That functionality comes from the serde and related crates so
we need to add them to our [dependencies]
in the Cargo.toml
file
and we’ll also enable the "serialization" feature in the tcod crate:
[dependencies]
tcod = { version = "0.15", features = ["serialization"] }
rand = "0.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
The serde
crate provides the main functionality for serializing and
deserializing of Rust data. This includes the Serialize
and
Deserialize
traits which describe what to do for a given
struct/enum/whatever. The optional derive
feature provides a way to derive those
traits so we don’t need to implement them ourselves and finally
serde_json
converts any serializable struct to and from JSON. If we
wanted to use a different format (YAML, TOML or anything else, we’d
replace this crate with another one that supports what you want).
Now let’s add those traits our source code, including the save_game
function and try to compile:
use std::error::Error;
use std::fs::File;
use std::io::{Read, Write};
use serde::{Deserialize, Serialize};
Unfortunately, the compilation will fail:
Compiling roguelike-tutorial v0.1.0 (file:///home/thomas/personal/code/roguelike-tutorial) error[E0277]: the trait bound `Game: serde::Serialize` is not satisfied --> src/bin/part-10-menu-saving.rs:1140:28 | 1140 | let save_data = serde_json::to_string(&(game, objects))?; | ^^^^^^^^^^^^^^^^^^^^^ the trait `serde::Serialize` is not implemented for `Game` | = note: required because of the requirements on the impl of `serde::Serialize` for `&Game` = note: required because of the requirements on the impl of `serde::Serialize` for `(&[Object], &Game)` = note: required by `serde_json::to_string`
Apparently, we need to implement the Serialize trait.
That tells Rust how to encode each bit of data in our structs. We can
do it manually, but it would be error-prone and really tedious.
Luckily, we can just use #[derive(Serialize)]
and have Rust do it
for us!
#[derive(Serialize)] (1)
struct Game {
map: Map,
messages: Messages,
inventory: Vec<Object>,
}
1 | The Game struct can be now serialised… sort of |
If you try to compile it now, you’ll see that the complaint has
shifted from Game
to Object
. We’ll need to derive Serialize
for every struct and enum we’ll be saving.
There is a complementary trait called Deserialize
which goes the other way:
from a serialised representation to a struct.
So let’s add them all at once:
#[derive(Serialize, Deserialize)]
struct Messages {
...
}
#[derive(Serialize, Deserialize)]
struct Game {
...
}
/// A tile of the map and its properties
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
struct Tile {
...
}
/// This is a generic object: the player, a monster, an item, the stairs...
/// It's always represented by a character on screen.
#[derive(Debug, Serialize, Deserialize)]
struct Object {
...
}
// combat-related properties and methods (monster, player, NPC).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
struct Fighter {
...
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
enum DeathCallback {
...
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
enum Ai {
...
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
enum Item {
...
}
It’s a bit of a bother but not the end of the world. After that, our
code should be compiling, though it will warn that save_game
is not
actually called from anywhere. Let’s fix that!
Traditionally, roguelikes only let you save when you’re quitting the
game, so we’ll call save_game
in play_game
right before the
break
that ends the game:
if player_action == PlayerAction::Exit {
save_game(game, objects).unwrap();
break;
}
If you run the game now and then quit it, you should see a new file
called savegame
created. You can look inside it — thanks to it
being JSON-encoded, it’s actually somewhat readable. It will contain
all the objects and the entire game state.
But if we’re not able to do anything with the save file, what use does
it have? We need to add a load_game
function, too:
fn load_game() -> Result<(Game, Vec<Object>), Box<dyn Error>> {
let mut json_save_state = String::new();
let mut file = File::open("savegame")?;
file.read_to_string(&mut json_save_state)?;
let result = serde_json::from_str::<(Game, Vec<Object>)>(&json_save_state)?;
Ok(result)
}
It’s basically just the reverse of save_game
: we read the save file
contents to a String, then we decode it into our objects
Vec and
Game
struct and if it all succeeds, return the pair.
In main_menu
we’ll handle the load game choice now:
match choice {
Some(0) => { // new game ... }
Some(1) => {
// load game
let (mut objects, mut game) = load_game().unwrap();
initialise_fov(tcod, &game.map);
play_game(&mut objects, &mut game, tcod);
}
Some(2) => { // quit ...}
_ => {}
}
To make things compile, you’ll have to derive Deserialize
to every
struct and enum that has the Serialize
trait, too.
After that, you should be able to load a previously saved game and continue playing!
But what if there is no game to load? Or if the file gets corrupted? We haven’t talked about error handling much, but now we need to.
The Rust book has a whole chapter on error handling so we’ll do only a tiny introduction. You should read that chapter.
Both save_game
and load_game
return a Result
value.
Result is similar to Option in that it has two
possibilities it can return — one that usually indicates a success
and the other failure. But in Result’s case, the failure can have
associated data as well. The successful variant is called `Ok
and the
failure is Err
.
There’s also the Error trait which represents an error and lets you get its textual description. All the file-handling serialisation errors in our save/load code implement Error.
So, looking at save_game
, the serde_json::to_string
call returns
either Ok(String)
with the encoded value or
Err(serde_json::error::Error)
on failure. File::create
and the
write_all
method work similarly although with different success and
error types.
Since we can return more then one error type, we return Box<Error>
instead. That lets us return any type that implements Error
and the
caller can get at the description and the raw error if they want.
The {?}[? operator] is a convenient way of saying "If this failed, return from the function with error immediately, otherwise give me the success value". The operator also does an extra conversion to the error type specified in the functions return value.
So let mut file = File::create("savegame")?;
is almost equivalent
to
fn save_game(game: &Game, objects: &[Object]) -> Result<(), Box<dyn Error>> {
...
let mut file = match File::create("savegame") {
Ok(f) => f,
Err(e) => return Err(e)
};
...
}
Only difference is that the ?
operator also does the conversion of the error to
whatever Error type the calling function asks for. In our case, since we’re using
Box<Error>
, no conversion will actually be done.
So, that explains the save/load game
functions. But what about using
their results?
If you tried to compile the game, you’ve seen this warning:
cargo build Compiling roguelike-tutorial v0.1.0 (file:///home/thomas/code/roguelike-tutorial) src/bin/part-10-menu-saving.rs:1123:13: 1123:38 warning: unused result which must be used, #[warn(unused_must_use)] on by default src/bin/part-10-menu-saving.rs:1123 save_game(objects, game); ^~~~~~~~~~~~~~~~~~~~~~~~~
It did not cause an error but Rust is being passive-aggressive about how we’re calling a function that can fail and then ignoring it.
So, we can keep ignoring it, try to actually handle the error or simply crash by calling unwrap :-)
Unwrap will make Rust happy that we’ve processed the Result, but it
will simply abort the program whenever we get Err
back.
Replace the call to save_game
with save_game(objects,
game).unwrap()
.
You have to realise however, that if this ever happens, it will make
your users really unhappy. If save_game
fails for any reason, your
game will just quit without any warning and the player will lose their
progress. You should always try to handle errors gracefully.
Let’s do that when we load the game. Calling load_game
right now
uses unwrap
as well, so if there is no game to load or something
similar, we’ll just quit the game.
We could just print a message and let the player start a new game instead:
Some(1) => {
// load game
match load_game() {
Ok((mut game, mut objects)) => {
initialise_fov(tcod, &game.map);
play_game(tcod, &mut game, &mut objects);
}
Err(_e) => {
msgbox("\nNo saved game to load.\n", 24, &mut tcod.root);
continue;
}
}
}
And we’ll add a function to display messages that relies on menu
to
do all the heavy lifting:
fn msgbox(text: &str, width: i32, root: &mut Root) {
let options: &[&str] = &[];
menu(text, options, width, root);
}
And that’s it! The actual saving and loading code was quite small, but we had to learn a ton of new stuff to understand it.
Here’s the complete code so far.
Continue to the next part.