About this tutorial
This tutorial is free and open source, and all code uses the MIT license - so you are free to do with it as you like. My hope is that you will enjoy the tutorial, and make great games!
If you enjoy this and would like me to keep writing, please consider supporting my Patreon.
In the last few chapters, we've focused on getting a playable (if not massively fun) game going. You can run around, slay monsters, and make use of various items. That's a great start! Most games let you stop playing, and come back later to continue. Fortunately, Rust (and associated libraries) makes it relatively easy.
If you're going to resume a game, you need somewhere from which to do so! A main menu also gives you the option to abandon your last save, possibly view credits, and generally tell the world that your game is here - and written by you. It's an important thing to have, so we'll put one together.
Being in the menu is a state - so we'll add it to the ever-expanding RunState
enum. We want to include menu state inside it, so the definition winds up looking like this:
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { AwaitingInput,
PreRun,
PlayerTurn,
MonsterTurn,
ShowInventory,
ShowDropItem,
ShowTargeting { range : i32, item : Entity},
MainMenu { menu_selection : gui::MainMenuSelection }
}
In gui.rs
, we add a couple of enum types to handle main menu selections:
#[derive(PartialEq, Copy, Clone)]
pub enum MainMenuSelection { NewGame, LoadGame, Quit }
#[derive(PartialEq, Copy, Clone)]
pub enum MainMenuResult { NoSelection{ selected : MainMenuSelection }, Selected{ selected: MainMenuSelection } }
Your GUI is probably now telling you that main.rs
has errors! It's right - we need to handle the new RunState
option. We'll need to change things around a bit to ensure that we aren't also rendering the GUI and map when in the menu. So we rearrange tick
:
fn tick(&mut self, ctx : &mut Rltk) {
let mut newrunstate;
{
let runstate = self.ecs.fetch::<RunState>();
newrunstate = *runstate;
}
ctx.cls();
match newrunstate {
RunState::MainMenu{..} => {}
_ => {
draw_map(&self.ecs, ctx);
{
let positions = self.ecs.read_storage::<Position>();
let renderables = self.ecs.read_storage::<Renderable>();
let map = self.ecs.fetch::<Map>();
let mut data = (&positions, &renderables).join().collect::<Vec<_>>();
data.sort_by(|&a, &b| b.1.render_order.cmp(&a.1.render_order) );
for (pos, render) in data.iter() {
let idx = map.xy_idx(pos.x, pos.y);
if map.visible_tiles[idx] { ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph) }
}
gui::draw_ui(&self.ecs, ctx);
}
}
}
...
We'll also handle the MainMenu
state in our large match
for RunState
:
RunState::MainMenu{ .. } => {
let result = gui::main_menu(self, ctx);
match result {
gui::MainMenuResult::NoSelection{ selected } => newrunstate = RunState::MainMenu{ menu_selection: selected },
gui::MainMenuResult::Selected{ selected } => {
match selected {
gui::MainMenuSelection::NewGame => newrunstate = RunState::PreRun,
gui::MainMenuSelection::LoadGame => newrunstate = RunState::PreRun,
gui::MainMenuSelection::Quit => { ::std::process::exit(0); }
}
}
}
}
We're basically updating the state with the new menu selection, and if something has been selected we change the game state. For Quit
, we simply terminate the process. For now, we'll make loading/starting a game do the same thing: go into the PreRun
state to setup the game.
The last thing to do is to write the menu itself. In menu.rs
:
pub fn main_menu(gs : &mut State, ctx : &mut Rltk) -> MainMenuResult {
let runstate = gs.ecs.fetch::<RunState>();
ctx.print_color_centered(15, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Rust Roguelike Tutorial");
if let RunState::MainMenu{ menu_selection : selection } = *runstate {
if selection == MainMenuSelection::NewGame {
ctx.print_color_centered(24, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Begin New Game");
} else {
ctx.print_color_centered(24, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Begin New Game");
}
if selection == MainMenuSelection::LoadGame {
ctx.print_color_centered(25, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Load Game");
} else {
ctx.print_color_centered(25, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Load Game");
}
if selection == MainMenuSelection::Quit {
ctx.print_color_centered(26, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Quit");
} else {
ctx.print_color_centered(26, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Quit");
}
match ctx.key {
None => return MainMenuResult::NoSelection{ selected: selection },
Some(key) => {
match key {
VirtualKeyCode::Escape => { return MainMenuResult::NoSelection{ selected: MainMenuSelection::Quit } }
VirtualKeyCode::Up => {
let newselection;
match selection {
MainMenuSelection::NewGame => newselection = MainMenuSelection::Quit,
MainMenuSelection::LoadGame => newselection = MainMenuSelection::NewGame,
MainMenuSelection::Quit => newselection = MainMenuSelection::LoadGame
}
return MainMenuResult::NoSelection{ selected: newselection }
}
VirtualKeyCode::Down => {
let newselection;
match selection {
MainMenuSelection::NewGame => newselection = MainMenuSelection::LoadGame,
MainMenuSelection::LoadGame => newselection = MainMenuSelection::Quit,
MainMenuSelection::Quit => newselection = MainMenuSelection::NewGame
}
return MainMenuResult::NoSelection{ selected: newselection }
}
VirtualKeyCode::Return => return MainMenuResult::Selected{ selected : selection },
_ => return MainMenuResult::NoSelection{ selected: selection }
}
}
}
}
MainMenuResult::NoSelection { selected: MainMenuSelection::NewGame }
}
That's a bit of a mouthful, but it displays menu options and lets you select them with the up/down keys and enter. It's very careful to not modify state itself, to keep things clear.
Serde
is pretty much the gold-standard for serialization in Rust. It makes a lot of things easier! So the first step is to include it. In your project's Cargo.toml
file, we'll expand the dependencies
section to include it:
[dependencies]
rltk = { git = "https://github.com/thebracket/rltk_rs", features = ["serialization"] }
specs = { version = "0.15.0", features = ["serde"] }
specs-derive = "0.4.0"
serde= { version = "1.0.93", features = ["derive"] }
serde_json = "1.0.39"
It may be worth calling cargo run
now - it will take a while, downloading the new dependencies (and all of their dependencies) and building them for you. It should keep them around so you don't have to wait this long every time you build.
We'll extend RunState
once more to support game saving:
#[derive(PartialEq, Copy, Clone)]
pub enum RunState { AwaitingInput,
PreRun,
PlayerTurn,
MonsterTurn,
ShowInventory,
ShowDropItem,
ShowTargeting { range : i32, item : Entity},
MainMenu { menu_selection : gui::MainMenuSelection },
SaveGame
}
In tick
, we'll add dummy code for now:
RunState::SaveGame => {
newrunstate = RunState::MainMenu{ menu_selection : gui::MainMenuSelection::LoadGame };
}
In player.rs
, we'll add another keyboard handler - escape:
// Save and Quit
VirtualKeyCode::Escape => return RunState::SaveGame,
If you cargo run
now, you can start a game and press escape to quit to the menu.
Now that the scaffolding is in place, it's time to actually save something! Lets start simple, to get a feel for Serde. In the tick
function, we extend the save system to just dump a JSON representation of the map to the console:
RunState::SaveGame => {
let data = serde_json::to_string(&*self.ecs.fetch::<Map>()).unwrap();
println!("{}", data);
newrunstate = RunState::MainMenu{ menu_selection : gui::MainMenuSelection::LoadGame };
}
We'll also need to add an extern crate serde;
to the top of main.rs
.
This won't compile, because we need to tell Map
to serialize itself! Fortunately, serde
provides some helpers to make this easy. At the top of map.rs
, we add use serde::{Serialize, Deserialize};
. We then decorate the map to derive serialization and de-serialization code:
#[derive(Default, Serialize, Deserialize, Clone)]
pub struct Map {
pub tiles : Vec<TileType>,
pub rooms : Vec<Rect>,
pub width : i32,
pub height : i32,
pub revealed_tiles : Vec<bool>,
pub visible_tiles : Vec<bool>,
pub blocked : Vec<bool>,
#[serde(skip_serializing)]
#[serde(skip_deserializing)]
pub tile_content : Vec<Vec<Entity>>
}
Note that we've decorated tile_content
with directives to not serialize/de-serialize it. This prevents us from needing to store the entities, and since this data is rebuilt every frame - it doesn't matter. The game still won't compile; we need to add similar decorators to TileType
and Rect
:
#[derive(PartialEq, Copy, Clone, Serialize, Deserialize)]
pub enum TileType {
Wall, Floor
}
#[derive(PartialEq, Copy, Clone, Serialize, Deserialize)]
pub struct Rect {
pub x1 : i32,
pub x2 : i32,
pub y1 : i32,
pub y2 : i32
}
Lastly, we should extend the game saving code to dump the map to the console:
let data = serde_json::to_string(&*self.ecs.fetch::<Map>()).unwrap();
println!("{}", data);
If you cargo run
the project now, when you hit escape it will dump a huge blob of JSON data to the console. That's the game map!
Now that we've seen how useful serde
is, we should start to use it for the game itself. This is harder than one might expect, because of how specs
handles Entity
structures: their ID # is purely synthetic, with no guaranty that you'll get the same one next time! Also, you may not want to save everything - so specs
introduces a concept of markers to help with this. It winds up being a bit more of a mouthful than it really needs to be, but gives a pretty powerful serialization system.
First of all, in main.rs
we'll tell Rust that we'd like to make use of the marker functionality:
use specs::saveload::{SimpleMarker, SimpleMarkerAllocator};
In components.rs
, we'll add a marker type:
pub struct SerializeMe;
Back in main.rs
, we'll add SerializeMe
to the list of things that we register:
gs.ecs.register::<SimpleMarker<SerializeMe>>();
We'll also add an entry to the ECS resources, which gets used to determine the next identity:
gs.ecs.insert(SimpleMarkerAllocator::<SerializeMe>::new());
Finally, in spawners.rs
we tell each entity builder to include the marker. Here's the complete entry for the Player
:
pub fn player(ecs : &mut World, player_x : i32, player_y : i32) -> Entity {
ecs
.create_entity()
.with(Position { x: player_x, y: player_y })
.with(Renderable {
glyph: rltk::to_cp437('@'),
fg: RGB::named(rltk::YELLOW),
bg: RGB::named(rltk::BLACK),
render_order: 0
})
.with(Player{})
.with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
.with(Name{name: "Player".to_string() })
.with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 })
.marked::<SimpleMarker<SerializeMe>>()
.build()
}
The new line (.marked::<SimpleMarker<SerializeMe>>()
) needs to be repeated for all of our spawners in this file. It's worth looking at the source for this chapter; to avoid making a huge chapter full of source code, I've omitted the repeated details.
It's pretty easy to serialize a type that doesn't have an Entity in it: mark it with #[derive(Component, Serialize, Deserialize, Clone)]
. So we go through all the simple component types in components.rs
; for example, here's Position
:
#[derive(Component, Serialize, Deserialize, Clone)]
pub struct Position {
pub x: i32,
pub y: i32,
}
Here is where it gets a little messy. There are no provided derive
functions for handling serialization of Entity
, so we have to do it the hard way. The good news is that we're not doing it very often. Here's a helper for InBackpack
:
// InBackpack wrapper
#[derive(Serialize, Deserialize, Clone)]
pub struct InBackpackData<M>(M);
impl<M: Marker + Serialize> ConvertSaveload<M> for InBackpack
where
for<'de> M: Deserialize<'de>,
{
type Data = InBackpackData<M>;
type Error = NoError;
fn convert_into<F>(&self, mut ids: F) -> Result<Self::Data, Self::Error>
where
F: FnMut(Entity) -> Option<M>,
{
let marker = ids(self.owner).unwrap();
Ok(InBackpackData(marker))
}
fn convert_from<F>(data: Self::Data, mut ids: F) -> Result<Self, Self::Error>
where
F: FnMut(M) -> Option<Entity>,
{
let entity = ids(data.0).unwrap();
Ok(InBackpack{owner: entity})
}
}
So we start off by making a "data" class for InBackpack
, which simply stores the entity at which it points. Then we implement convert_info
and convert_from
to satisfy Specs' ConvertSaveLoad
trait. In convert_into
, we use the ids
map to get a saveable ID number for the item, and return an InBackpackData
using this marker. convert_from
does the reverse: we get the ID, look up the ID, and return an InBackpack
method.
So that's not too bad. If you look at the source, we've done this for all of the types that store Entity
data - some of which have other data, or multiple Entity
types.
The code for loading and saving gets large, so we've moved it into saveload_system.rs
. Then include a mod saveload_system;
in main.rs
, and replace the SaveGame
state with:
RunState::SaveGame => {
saveload_system::save_game(&mut self.ecs);
newrunstate = RunState::MainMenu{ menu_selection : gui::MainMenuSelection::LoadGame };
}
So... onto implementing save_game
. Serde and Specs work decently together, but the bridge is still pretty roughly defined. I kept running into problems like it failing to compile if I had more than 16 component types! To get around this, I build a macro. I recommend just copying the macro until you feel ready to learn Rust's (impressive) macro system.
macro_rules! serialize_individually {
($ecs:expr, $ser:expr, $data:expr, $( $type:ty),*) => {
$(
SerializeComponents::<NoError, SimpleMarker<SerializeMe>>::serialize(
&( $ecs.read_storage::<$type>(), ),
&$data.0,
&$data.1,
&mut $ser,
)
.unwrap();
)*
};
}
The short version of what it does is that it takes your ECS as the first parameter, and a tuple with your entity store and "markers" stores in it (you'll see this in a moment). Every parameter after that is a type - listing a type stored in your ECS. These are repeating rules, so it issues one SerializeComponent::serialize
call per type. It's not as efficient as doing them all at once, but it works - and doesn't fall over when you exceed 16 types! The save_game
function then looks like this:
pub fn save_game(ecs : &mut World) {
// Create helper
let mapcopy = ecs.get_mut::<super::map::Map>().unwrap().clone();
let savehelper = ecs
.create_entity()
.with(SerializationHelper{ map : mapcopy })
.marked::<SimpleMarker<SerializeMe>>()
.build();
// Actually serialize
{
let data = ( ecs.entities(), ecs.read_storage::<SimpleMarker<SerializeMe>>() );
let writer = File::create("./savegame.json").unwrap();
let mut serializer = serde_json::Serializer::new(writer);
serialize_individually!(ecs, serializer, data, Position, Renderable, Player, Viewshed, Monster,
Name, BlocksTile, CombatStats, SufferDamage, WantsToMelee, Item, Consumable, Ranged, InflictsDamage,
AreaOfEffect, Confusion, ProvidesHealing, InBackpack, WantsToPickupItem, WantsToUseItem,
WantsToDropItem, SerializationHelper
);
}
// Clean up
ecs.delete_entity(savehelper).expect("Crash on cleanup");
}
What's going on here, then?
SerializationHelper
that stores a copy of the map (see, we are using the map stuff from above!). It then creates a new entity, and gives it the new component - with a copy of the map (the clone
command makes a deep copy). This is needed so we don't need to serialize the map separately.data
to be a tuple, containing the Entity
store and ReadStorage
for SimpleMarker
. These will be used by the save macro.File
called savegame.json
in the current directory.serialize_individually
macro with all of our types.If you cargo run
and start a game, then save it - you'll find a savegame.json
file has appeared - with your game state in it. Yay!
Now that we have the game data, it's time to load it!
First, we need to know if there is a saved game to load. In saveload_system.rs
, we add the following function:
pub fn does_save_exist() -> bool {
Path::new("./savegame.json").exists()
}
Then in gui.rs
, we extend the main_menu
function to check for the existence of a file - and not offer to load it if it isn't there:
pub fn main_menu(gs : &mut State, ctx : &mut Rltk) -> MainMenuResult {
let save_exists = super::saveload_system::does_save_exist();
let runstate = gs.ecs.fetch::<RunState>();
ctx.print_color_centered(15, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Rust Roguelike Tutorial");
if let RunState::MainMenu{ menu_selection : selection } = *runstate {
if selection == MainMenuSelection::NewGame {
ctx.print_color_centered(24, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Begin New Game");
} else {
ctx.print_color_centered(24, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Begin New Game");
}
if save_exists {
if selection == MainMenuSelection::LoadGame {
ctx.print_color_centered(25, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Load Game");
} else {
ctx.print_color_centered(25, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Load Game");
}
}
if selection == MainMenuSelection::Quit {
ctx.print_color_centered(26, RGB::named(rltk::MAGENTA), RGB::named(rltk::BLACK), "Quit");
} else {
ctx.print_color_centered(26, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), "Quit");
}
match ctx.key {
None => return MainMenuResult::NoSelection{ selected: selection },
Some(key) => {
match key {
VirtualKeyCode::Escape => { return MainMenuResult::NoSelection{ selected: MainMenuSelection::Quit } }
VirtualKeyCode::Up => {
let mut newselection;
match selection {
MainMenuSelection::NewGame => newselection = MainMenuSelection::Quit,
MainMenuSelection::LoadGame => newselection = MainMenuSelection::NewGame,
MainMenuSelection::Quit => newselection = MainMenuSelection::LoadGame
}
if newselection == MainMenuSelection::LoadGame && !save_exists {
newselection = MainMenuSelection::NewGame;
}
return MainMenuResult::NoSelection{ selected: newselection }
}
VirtualKeyCode::Down => {
let mut newselection;
match selection {
MainMenuSelection::NewGame => newselection = MainMenuSelection::LoadGame,
MainMenuSelection::LoadGame => newselection = MainMenuSelection::Quit,
MainMenuSelection::Quit => newselection = MainMenuSelection::NewGame
}
if newselection == MainMenuSelection::LoadGame && !save_exists {
newselection = MainMenuSelection::Quit;
}
return MainMenuResult::NoSelection{ selected: newselection }
}
VirtualKeyCode::Return => return MainMenuResult::Selected{ selected : selection },
_ => return MainMenuResult::NoSelection{ selected: selection }
}
}
}
}
MainMenuResult::NoSelection { selected: MainMenuSelection::NewGame }
}
Finally, we'll modify the calling code in main.rs
to call game loading:
RunState::MainMenu{ .. } => {
let result = gui::main_menu(self, ctx);
match result {
gui::MainMenuResult::NoSelection{ selected } => newrunstate = RunState::MainMenu{ menu_selection: selected },
gui::MainMenuResult::Selected{ selected } => {
match selected {
gui::MainMenuSelection::NewGame => newrunstate = RunState::PreRun,
gui::MainMenuSelection::LoadGame => {
saveload_system::load_game(&mut self.ecs);
newrunstate = RunState::AwaitingInput;
}
gui::MainMenuSelection::Quit => { ::std::process::exit(0); }
}
}
}
}
In saveload_system.rs
, we're going to need another macro! This is pretty much the same as the serialize_individually
macro - but reverses the process, and includes some slight changes:
macro_rules! deserialize_individually {
($ecs:expr, $de:expr, $data:expr, $( $type:ty),*) => {
$(
DeserializeComponents::<NoError, _>::deserialize(
&mut ( &mut $ecs.write_storage::<$type>(), ),
&mut $data.0, // entities
&mut $data.1, // marker
&mut $data.2, // allocater
&mut $de,
)
.unwrap();
)*
};
}
This is called from a new function, load_game
:
pub fn load_game(ecs: &mut World) {
{
// Delete everything
let mut to_delete = Vec::new();
for e in ecs.entities().join() {
to_delete.push(e);
}
for del in to_delete.iter() {
ecs.delete_entity(*del).expect("Deletion failed");
}
}
let data = fs::read_to_string("./savegame.json").unwrap();
let mut de = serde_json::Deserializer::from_str(&data);
{
let mut d = (&mut ecs.entities(), &mut ecs.write_storage::<SimpleMarker<SerializeMe>>(), &mut SimpleMarkerAllocator::<SerializeMe>::new());
deserialize_individually!(ecs, de, d, Position, Renderable, Player, Viewshed, Monster,
Name, BlocksTile, CombatStats, SufferDamage, WantsToMelee, Item, Consumable, Ranged, InflictsDamage,
AreaOfEffect, Confusion, ProvidesHealing, InBackpack, WantsToPickupItem, WantsToUseItem,
WantsToDropItem, SerializationHelper
);
}
let mut deleteme : Option<Entity> = None;
{
let entities = ecs.entities();
let helper = ecs.read_storage::<SerializationHelper>();
let player = ecs.read_storage::<Player>();
let position = ecs.read_storage::<Position>();
for (e,h) in (&entities, &helper).join() {
let mut worldmap = ecs.write_resource::<super::map::Map>();
*worldmap = h.map.clone();
worldmap.tile_content = vec![Vec::new(); super::map::MAPCOUNT];
deleteme = Some(e);
}
for (e,_p,pos) in (&entities, &player, &position).join() {
let mut ppos = ecs.write_resource::<rltk::Point>();
*ppos = rltk::Point::new(pos.x, pos.y);
let mut player_resource = ecs.write_resource::<Entity>();
*player_resource = e;
}
}
ecs.delete_entity(deleteme.unwrap()).expect("Unable to delete helper");
}
That's quite the mouthful, so lets step through it:
savegame.json
file, and attach a JSON deserializer.SerializationHelper
type. If we find it, we get access to the resource storing the map - and replace it. Since we aren't serializing tile_content
, we replace it with an empty set of vectors.Player
type and a Position
type. We store the world resources for the player entity and his/her position.If you cargo run
now, you can load your saved game!
It wouldn't really be a roguelike if we let you keep your save game after you reload! So we'll add one more function to saveload_system
:
pub fn delete_save() {
if Path::new("./savegame.json").exists() { std::fs::remove_file("./savegame.json").expect("Unable to delete file"); }
}
We'll add a call to main.rs
to delete the save after we load the game:
gui::MainMenuSelection::LoadGame => {
saveload_system::load_game(&mut self.ecs);
newrunstate = RunState::AwaitingInput;
saveload_system::delete_save();
}
This has been a long chapter, with quite heavy content. The great news is that we now have a framework for loading and saving the game whenever we want to. Adding components has gained some steps: we have to register them in main
, tag them for Serialize, Deserialize
, and remember to add them to our component type lists in saveload_system.rs
. That could be easier - but it's a very solid foundation.
The source code for this chapter may be found here
Copyright (C) 2019, Herbert Wolverson.