How to make a Text Adventure game in Rust - IX - Adding a Game File - Part II
In this project, we're making a fully functional text adventure game from scratch. The post assumes you've read the earlier posts in the series. To get caught up jump to the first post and start at the beginning. This project is heavily based on the work of Ruud Helderman and closely follows the content and structure of his excellent tutorial series on the same topic using the C language.
This content and all code is licensed under the MIT License.
Photo by Frank Cone |
9 - Adding a Game File - Part II
In the last post we began moving the definition of the game out of the source and into a file using the Serde crate. By the end of the post we had modified the game to first write and then read the game definition from a file.
But the file, while functional, did not satisfy all of the goals we had set out to accomplish. Instead of using names to refer to locations and destinations, the file used integer indexes into the list of game objects. Left as is, the indexes would be difficult to keep correct as the file got larger. Changes to the file such as adding new objects or moving around existing ones would mean updating numerous entries and almost surely create errors in the game. Using Serde we can create custom serialize and deserialize functions to eliminate the use of indexes and use object names instead to store object relationships. This approach would be much less prone to error and enable moving objects around easily.
In this post, we'll implement custom serialize and deseralize functions that extend the solution we implemented in the last post to use named locations and destinations.
Adding a Game File Step 3 - Named Location and Destinations with Custom Serialization and Deserialization
We showed in the last post that Serde macros can automatically create serialize and deseralize functions. But as is, the World
struct uses integer indexes for the location
and destination
fields. The automatically generated serialization and deserialization functions reflect this and thus the game file has integer indexes in it. If the game instead used a struct that used names to refer to other objects we could use Serde generated functions to serialize and deserialize them and we'd have the file we want.
Lets explore this approach more fully. The current implementation works as shown in the figure below. World
structs contain a Vec
of Object
structs, and the Object
structs have a number of attributes. The problem is the location
and destination
attributes, which are of type Option<usize>
. Because of this, the serialized representation is as an Option
and we see values like Some(7)
, and None
in the game file.
SavedWorld
and SavedObject
respectively. These new types will use Strings
to represent location
and destination
so the game file will contain strings as we want. To convert from our 'normal' objects, we'll implement the From
and Into
trait on the normal objects with their saved counterparts.To make this work we'll use the following process
- Create alternate versions of the
World
andObject
structs that have the named values we want, - Write
From
andInto
trait implementations to convert between the normal and save structs, - Implement custom
serialize
anddeserialize
functions for the normal structs, and - Update the game file and put it all together.
Task 1 - Create alternate World
and Object
structs
The first task is to create new structs that use named values. We'll call them SavedWorld
and SavedObject
to reflect that they are going to be saved. They look like this:
// --snip--
#[derive(Serialize, Deserialize, Debug)]
pub struct Object {
pub labels: Vec<String>,
pub description: String,
pub location: Option<usize>,
pub destination: Option<usize>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct World {
pub objects: Vec<Object>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct SavedObject {
pub labels: Vec<String>,
pub description: String,
pub location: String,
pub destination: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct SavedWorld {
pub objects: Vec<SavedObject>,
}
// --snip--
Explanation
2-8 - The 'normal' Object
struct. No changes here.
10-13 - The 'normal' World
struct. No changes here.
15-21 - The 'saved' Object
struct. Note the use of String
for location
and destination
.
23-26 - The 'saved' World
struct. Note the Vec
is now a Vec
of SavedObject
structs.
That is it for task 1! BUT, Notice that we've included derive
for Serialize
and Deserialize
trait implementations on both of the new structs. That is so that we can use Serde to automatically serialize these structs for us. The derived implementations allow writing and reading SavedWorld
and SavedObject
structs to and from the game file respectively. If we can get our game data into SavedWorld
and SavedObject
structs we would be able to go all the way from our game code out to the file and back. That is what Task 2 is all about. Lets go there now.
Task 2 - Create From
and Into
trait implementations
Next up is to create implementations of the From
and Into
traits for our new SavedObject
and SavedWorld
structs that allow the game to convert between them and the normal Object
and World
structs. We'll use these as part of the serialization and deserialization of Object
and World
to convert to and from their normal form into the saved form for which can use derived serialization functions.
We will implement the From
trait first. The From
trait implementation needs to take in a World
struct and return a SavedWorld
struct as output. Mostly the implementation is walking the objects
vector and for each one constructing a corresponding SavedObject
instance that converts the object
struct's location
and destination Options
into Strings
.
impl From<&World> for SavedWorld {
fn from(value: &World) -> Self {
let mut new_vec_of_objects: Vec<SavedObject> = Vec::new();
for item in &value.objects {
new_vec_of_objects.push(SavedObject {
labels: item.labels.clone(),
description: item.description.to_string(),
location: match item.location {
Some(location) => value.objects[location].labels[0].to_string(),
None => "".to_string(),
},
destination: match item.destination {
Some(destination) => value.objects[destination].labels[0].to_string(),
None => "".to_string(),
},
});
}
SavedWorld {
objects: new_vec_of_objects,
}
}
}
Explanation
1 - The From
trait implementation for SavedWorld
. This implementation is generic for World
structs.
2 - The from
function for the trait. Accepts a reference to a World
struct (called value
) and returns a SavedObject
struct.
3 - A mutable vector of SavedObjects
. Used to accumulate the converted Object
structs as we do the conversion.
5-18 - Loop through the objects
vector of the passed World
.
6-17 - For each object
construct a new SavedObject
.
9-12 - Set location
using a lookup into the source World.objects
list. Use the first label as the object
name.
13-16 - Set destination
using a lookup into the source World.objects
list. Use the first label as the object
name.
20-22 - Create a new SavedWorld
struct. Set its objects
field to the list of accumulated SavedObject
structs.
The lookups on lines 10 and 14 have two important ramifications. First is that cannonical name for an object is the first value in the labels array. That value will be used to refer to the object and is the value that will appear in SavedObject
and in the saved file to refer to the object. This leads to the second important point, which is that the first value in the labels vec must be unique across ALL objects. This means that when naming objects, the most specific name should be the first one in the list of labels. Other simplified names, such as 'photo' to refer to a 'glossy photo,' should appear later in the list.
Let us now look at implementing the Into
trait. Into
accomodates the case of reading from a saved game file. Implementing Into
is complicated by the fact that Into
can fail. What might happen, for example, if the user enters a location
or destination
that doesn't exist? This is not a case of bad syntax (Serde will catch that for us), but rather of well formed, but inconsistent content in the game file. It turns out that Rust has a variant of Into
called TryInto
that accommodates this very case. TryInto
returns a Result
which allows us to catch and then handle the errors that can arise from mappings that fail.
Implementing TryInto
requires implementing a single function try_into()
, but it also requires the use of a defined error type. For our implementation, we'll create a custom type ParseError
that we can use to represent errors that arise when reading saved game files. Implementing ParseError
is straight forward, but does warrant some explanation, so we'll review it separately here. When implementing an error type, we first implement the type, and then implement the std error::Error
trait on the type. Additionally, types that implement std error::Error
must also implement Debug
and Display
. Those implementations are also shown here.
use std::error
// --snip--
#[derive(Debug)]
pub enum ParseError {
UnknownName(String),
}
impl error::Error for ParseError {}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ParseError::UnknownName(message) => write!(f, "{}", message),
}
}
}
Explanation
1 - The Error
trait is included from std::error
5-7 - The error type we need to implement. The implementation is a simple enum with a string annotation. Here we only show one error enum value, but we could easily implement others if needed.
9 - Implementing the Error
trait for ParseError
4 - ParseError
must implement Debug
. We can use the derived Debug
implementation.
11-17 - ParseError
must also implement the Display
trait. This implementation just passes through the string that is included in the error itself.
With the required error type implemented, we can now implement the TryInto
trait. As stated previously, TryInto
only requires the implementation of one function, try_into()
. The implementation here operates on a SavedWorld
struct, looping over its vector of SavedObjects
to accumulate a new vector of Objects
that it then returns inside a newly created World
struct. For each found SavedObject
, the implementation creates a new Object
struct. The implementation copies the labels
and description
values from the source struct. For the location
and destination
values, the implementation loops over the objects vec
in SavedWorld
to find the appropriate index to use in the new object. The found_location
and found_destination
booleans are used to detect that both a location
and destination
are found. If both are found, then the loop short circuits to move onto the next Object
. If either are not found, the implementation returns a ParseError
with the name of the unmatched location
or destination
string.
impl Object {
fn new(
new_labels: Vec<String>,
new_description: String,
new_location: Option<usize>,
new_destination: Option<usize>,
) -> Object {
Object {
labels: new_labels,
description: new_description,
location: new_location,
destination: new_destination,
}
}
}
impl TryInto<World> for SavedWorld {
type Error = ParseError;
fn try_into(self) -> Result<World, Self::Error> {
let mut new_vec_of_objects: Vec<Object> = Vec::new();
'items: for item in &self.objects {
let mut new_object = Object::new(
item.labels.clone(),
item.description.to_string(),
None,
None,
);
let mut found_location: bool = item.location.is_empty();
let mut found_destination: bool = item.destination.is_empty();
for (pos, internal_item) in self.objects.iter().enumerate() {
if item.location == internal_item.labels[0] {
new_object.location = Some(pos);
found_location = true;
}
if item.destination == internal_item.labels[0] {
new_object.destination = Some(pos);
found_destination = true;
}
if found_location && found_destination {
new_vec_of_objects.push(new_object);
continue 'items;
}
}
if !found_location {
return Err(ParseError::UnknownName(format!(
"Unknown location '{}'",
item.location
)));
}
if !found_destination {
return Err(ParseError::UnknownName(format!(
"Unknown destination '{}'",
item.destination
)));
}
new_vec_of_objects.push(new_object);
return Err(ParseError::UnknownName("How are we here?".into()));
}
let result_world = World {
objects: new_vec_of_objects,
};
Ok(result_world)
}
}
Explanation
1-15 - Helper implementation of new()
for Object
Used by the try_into()
implementation.
17 - Implementation of the TryInto
trait for SavedWorld
. The implementation is generic for World
structs.
18 - Define the type association for Error
to associate our ParseError
type as the error type used in this trait implementation.
20 - The try_into()
function. Returns a Result<World, Self::Error>
. The Self::Error
here will be a ParseError
because of the type association on line 2.
23-65 - Loop through each SavedObject
in the SavedWorld
struct.
24-29 - For each SavedObject
, create a new Object
and copy the labels
and description
values. Make it mutable so we can update the location
and destination
values later.
31-47 - Try to find location
and destination
values by looping over our list of SavedObject
structs. Short circuit the loop if we find both before the end of the vector of SavedObject
structs.
49-61 - If we haven't found location
or destination
after checking each SavedObject
then this must be an undefined name. Return an error.
63-64 - Catch all error for any cases we haven't considered. This is most likely dead code that can never be executed.
67-69 - If we're here we've matched all the locations
and destinations
in the vector of SavedObjects
and have accumulated a vector of Object
structs. The lines here construct a new World
struct for return.
71 - Success. Return the constructed World
struct.
With the From
and TryInto
traits we now have implementations that can move data from our in game World
and Object
structs into and out of the SavedWorld
and SavedObject
structs. Recall that in Task 1, we have Serde derived methods that will write those structs out to a file and back. That is the entire path we need. We could stop here and manually convert the game World
struct into a SavedWorld
struct before writing (or reading) to the game file, but that would complicate our read_from_file
function. Instead, we can create custom serializer and deserializer functions that wrap these conversions and abstarct the SavedWorld
and SavedObject
structs away from the other game code. We'll do that in our last task, Task 3.
Task 3 - Implement custom serialize
and deserialize
functions for the normal structs
In this section we'll implement the Serialize
and Deserialize
traits for the World
struct. These implementations will encapsulate all the work we've done in this post and make it look like we're serializing the World
and Object
structs directly.
We'll start with the implementation of the Serialize
trait. Its implementation is just as you might expect - when called on a World
struct, the implementation first pushes the World
struct into a SavedWorld
struct, and then uses Serde to serialize that struct.
use serde::ser::{SerializeStruct, Serializer};
// --snip--
#[derive(Debug)]
pub struct World {
pub objects: Vec<Object>,
}
// --snip--
impl Serialize for World {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let serializeable_struct: SavedWorld = SavedWorld::from(self);
// 1 is the number of fields in the struct.
let mut state = serializer.serialize_struct("SavedWorld", 1)?;
state.serialize_field("objects", &serializeable_struct.objects)?;
state.end()
}
}
Explanation
1 - Added dependency for the Serialize
trait.
4-7 - Here we're adjusting the implementation of World
to remove the derived implementation of Serialize
and Deserialize
. Removing the derived versions here is required before implementing custom versions. (Note that the Deserialize
trait is implemented in a separate snippet, below).
11 - Implementation of the Serialize
trait for the World
struct.
12-14 - The serialize()
function declaration. The where
keyword is a type constraint that requires that the generic for the function implements the Serializer
trait.
16 - Use the from()
implementation from the From
trait implemented on SavedWorld
to convert the World
struct into a SavedWorld
struct.
18-21 - These lines are the normal Serde serialization pattern to serialize a struct. First call serialize_struct()
with the type and number of fields, then serialize each field, then end to complete the serialization.
The implementation of the Serialize
trait is easy to digest. Serde deserialization, however, is quite a bit more complex to follow. The complexity arises from Serde's heavy use of generics, Serde's pattern of declaring embedded structs and functions, the fact that in Serde deserializers must deal with the different ways that a value can be serialized (as either a sequence or a map in the case of a struct) and the use of the visitor pattern in Serde. For brevity, we won't describe here all or even most of the deserialization code. There are better and more comprehensive descriptions of deserialization on the Serde site. We will provide (hopefully) enough high level commentary to emphasize what makes the implementation here work as needed.
use serde::de::{self, Deserializer, Error, MapAccess, SeqAccess, Visitor};
// --snip--
impl SavedWorld {
fn new(new_objects: Vec<SavedObject>) -> SavedWorld {
SavedWorld {
objects: new_objects,
}
}
}
// --snip--
impl<'de> Deserialize<'de> for World {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
enum Field {
Objects,
}
impl<'de> Deserialize<'de> for Field {
fn deserialize<D>(deserializer: D) -> Result<Field, D::Error>
where
D: Deserializer<'de>,
{
struct FieldVisitor;
impl<'de> Visitor<'de> for FieldVisitor {
type Value = Field;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("`objects`")
}
fn visit_str<E>(self, value: &str) -> Result<Field, E>
where
E: de::Error,
{
match value {
"objects" => Ok(Field::Objects),
_ => Err(de::Error::unknown_field(value, FIELDS)),
}
}
}
deserializer.deserialize_identifier(FieldVisitor)
}
}
struct SavedWorldVisitor;
impl<'de> Visitor<'de> for SavedWorldVisitor {
type Value = SavedWorld;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("struct SavedWorld")
}
fn visit_seq<V>(self, mut seq: V) -> Result<SavedWorld, V::Error>
where
V: SeqAccess<'de>,
{
let objects = seq
.next_element()?
.ok_or_else(|| de::Error::invalid_length(1, &self))?;
Ok(SavedWorld::new(objects))
}
fn visit_map<V>(self, mut map: V) -> Result<SavedWorld, V::Error>
where
V: MapAccess<'de>,
{
let mut objects = None;
while let Some(key) = map.next_key()? {
match key {
Field::Objects => {
if objects.is_some() {
return Err(de::Error::duplicate_field("objects"));
}
objects = Some(map.next_value()?);
}
}
}
let objects = objects.ok_or_else(|| de::Error::missing_field("objects"))?;
Ok(SavedWorld::new(objects))
}
}
const FIELDS: &[&str] = &["objects"];
let internal_extract = deserializer.deserialize_struct("World", FIELDS, SavedWorldVisitor);
match internal_extract {
Ok(extracted_val) => {
let external_val = extracted_val.try_into();
match external_val {
Ok(result_val) => Ok(result_val),
// From here: https://serde.rs/convert-error.html
// But that lacks context, this one is better:
// https://stackoverflow.com/questions/66230715/make-my-own-error-for-serde-json-deserialize
Err(_) => external_val.map_err(D::Error::custom),
}
}
Err(err_val) => Err(err_val),
}
}
}
Explanation
1 - Added dependencies for the Deserialize
and other traits used in the implementation here.
4-10 - Helper implementation of new()
for SavedWorld
Used in the Deserialize
implementation.
14 - Implementation of the Deserialize
trait for the World
struct.
15-17 - The deserialize()
function declaration. The where
keyword is a type constraint that requires that the generic for the function implements the Deserializer
trait.
18-105 - Implementation of the deserialize()
function. This implementation follows the patterns in typical use for Serde as described in the Serde documentation. This pattern makes use of embedded structures and functions as shown here.
19-50 - deserialize()
implementation part 1, containing the definition of Field
and an embedded Deserialize
implementation for that enum
. This code is called during deserialization on lines 66 and 75.
52-88 - deserialize()
implementation part 2, containing the visitor implementation for the SavedWorld
struct. This code is called during deserialization on line 91.
90-104 - deserialize()
implementation part 3. This section is most analagous to the previous serialize()
implementation.
91 - Attempts to deserialize a SavedWorld
struct using the defined visitor
94 - If successful attempt to convert the extracted SavedWorld
into a World
struct using try_into()
.
96 - If successully converted to a World
result, return the result.
100 - If not successful, return an error. Of note here is that the returned error from try_into()
is of type ParseError
and the type that deserialize()
must return is of type de::Deserializer::Error
. The description provided by Serde for error conversion and a Stack Overflow post proved to be particularly instructive in helping me to find the map_err()
function.
With this implementation, we now have fully encapsulated the round trip from the game's internal state representation (in the World
struct) out through SavedWorld
and to a game file persisted on disk in an easy to read and update format. In the next section, we'll put it all together to have the game read information from a file.
Task 4 - Update the game file and put it all together
In the last post, we used a modified version of the read_from_file()
function to write a version of the file game_file.ron
based on the statically defined World
definition in the game code. With the serialization and deserialization code implemented here we need a new game file that contains string based names for location
and destination
. We can use the same modified version of read_from_file()
we used previously to create an updated game file. If we revert to that code and re-run the game, we see the following out.
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.02s
Running `/home/rskerr/dev/reentry/target/debug/reentry`
serialized = (objects:[(labels:["Bridge"],description:"the bridge",location:"",destination:""),...])
Welcome to Reentry. A space adventure.
You awake in darkness with a pounding headache.
An alarm is flashing and beeping loudly. This doesn't help your headache.
> quit
Quitting.
Thank you for playing!
Bye!
As previously, we can reformat the output and arrive at a file like so. (We've snipped out most of the file for brevity, but the full file is included in the code repository.) Notice that the location
and destination
fields are now strings that refer to other objects by label.
//
// Reentry
//
// A game by Riskpeep
World (
objects : [
(labels : ["Bridge"],
description : "the bridge",
location : "",
destination : ""
),
// --snip--
(labels : ["Glossy Photo", "Photo"],
description : "a glossy photo of a family. They look familiar",
location : "Bridge",
destination : ""
),
// --snip--
(labels : ["Forward"],
description : "a passage forward to the galley",
location : "Cryochamber",
destination : "Galley"
),
// --snip--
]
)
We now can save this new file as game_file.ron
(replacing the previous one), and reset read_from_file()
back to the unmodified version. Now, when we run the game, the game definition will come from our newly modified game_file.ron
file. To be certain the game is pulling content from the file, we can modify the game file to provide an updated description for the bridge.
// --snip--
(labels : ["Bridge"],
description : "the bridge. Switches, dials, and blinking lights cover the walls.",
location : "",
destination : ""
),
// --snip--
The above change exists only in the game file and not in the game's source code. When we run the game we can confirm that the game content comes from the file.
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
Running `/home/rskerr/dev/reentry/target/debug/reentry`
Welcome to Reentry. A space adventure.
You awake in darkness with a pounding headache.
An alarm is flashing and beeping loudly. This doesn't help your headache.
> look
Bridge
You are in the bridge. Switches, dials, and blinking lights cover the walls..
You see:
a glossy photo of a family. They look familiar
a passage aft to the galley
a bulkhead covered in switchpanels and gauges
> quit
Quitting.
Thank you for playing!
Bye!
The description shows the edited string. Success!
Progress
With these changes, we now have a much more easily editable game file. While using strings to indicate locations
and destinations
is more verbose than using numbers, using location names is much more readable and thus far less likely to result in confusion when editing the game file. In addition, we can now move objects around, add new objects in the middle of the file, and delete objects without having to update indexes across the entire file. Changes are much more likely to be contained in the changed objects and those they connect to.
So... Can we declare mission accomplished now?
Lets once again review our goals and ask ourselves if we've accompliched them.
- Is the file separate from the game's source code? Yes,
- Is the file written in a format of our choosing? Yes,
- Does the solution allow us to easily add and edit game objects? Yes!
- Does the solution allow us to have many attributes on objects? Yes
This is an overall win. We accomplished all of our goals and now have a way to edit the game without rebuilding the program every time. There are still a few enhancements we might make to, for example, set the game introduction text, adjust verb names, error responses, or any other of the hard coded strings in the game. Such changes would be an extension to the implementation we've accomplished here and would move the game to a more fully implemented game engine.
In the next post, we'll look at adding additional attributes to game objects that will further enhance the player's experience and interactivity in the game.
Comments
Post a Comment