How to make a Text Adventure game in Rust - X - More Attributes

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 beginningThis 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

10 - Additional Attributes

Text adventures work best when the game world creates a believable sense of immersion in the game world. Players come into the game with an understanding of how the world works and the game should reflect that. Departures from reality then can become deliberate parts of the game as part of game puzzles or elements of the game world that make it unique. For example, players would not expect to be able to put a space ship into a tiny box. But maybe they can put a space ship into a tiny MAGIC box. In this post, we'll add attributes to objects to provide additional descriptive text to objects, additional text hints for following passages and using container objects, passages that can block or misdirect players when traversed, weight and capacity for objects, and health for game actors. To begin, lets examine each of these more closely.

New Attributes

  1. When moving through the game world, players will see descriptions of the locations that they move through. The commands look and look around will show these descriptions. But what if a player looks at an object in the game? Looking closely at a photo should give the player additional information, maybe even a hint for how to solve some game puzzle. We can add a details attribute to capture the responses for the game when examining an object closely. If no details attribute is defined, we can use the string "You see nothing special." to provide a reasonable answer.
  2. Similarly, when looking at objects that contain other objects, the current "You see", can be enhanced with specific strings like "You have", or "The bag holds", etc. that provide better descriptions. We'll add contents to capture this string.
  3. When moving through a passage with a command such as go aft, the game will normally respond with OK followed by a description of the new location and perhaps a list of visible objects and passages. We can add depth to the game world by changing these OK responses with descriptions that are unique to the passage. Instead of OK, the game might say You squeeze through the narrow cleft and emerge on cliff. We'll add an attribute text_go to contain this message. If no value is defined we can use the string "You can't get much closer than this."
  4. ALso with passages, sometimes passage have a 'twist'; they do not go where the player expects them to go. For example, a passage through the woods might appear to go from location A to location B, but in reality the destination is location C, the bottom of a pit. Another 'twist' could be a passage that is blocked by a guard or a locked door. Attempting to follow the path should just return the player to the current location. We need to separate the actual destination from the apparent destination for a passage. We'll add an attribute prospect to represent the apparent location. The existing destination attribute will still be used to hold the real destination. In most cases, these two will be the same, so we'll code the game so prospect only needs to be specified if it is different from the destination. If no prospect is given then the game will use the destination.
  5. Earlier we mentioned that players should not expect to be able to put large objects into smaller ones. Similarly, players would expect that they cannot pick up every object in the game. Even the strongest person cannot lift a house! There are really two concepts here (size and weight), but we can approximate both of them with a weight attribute and a corresponding capacity attribute. Very large objects can have a high weight to make them immovable. If no weight is specified for an object, a default value of '99' will be large enough that the player cannot move it. Similarly, since most objects cannot hold other objects, a default value of '0' is appropriate for capacity.
  6. Finally, many games will have other actors and sometimes their health (or lack of health for an enemy) plays a role in a game puzzle. Perhaps a player will need to injure an opposing actor to be able to move past them. We will add a health attribute to capture this concept. Objects with zero health are either dead or not actors at all. We'll set the default value for health as '0' since most objects are not alive.

Adding Attributes - Step 1 - Defining the attributes

Collecting up the new attributes, we have the following attributes. We can examine each and consider appropriate types for each.

  1. details, contents and text_go - Need to contain arbitrary length strings. String will work for this purpose.
  2. prospect - Needs to contain the index of a game object. Like destination, we can use Option<usize>.
  3. weight, capacity, and health - Need to contain an integer value. The obvious choice would be be an unsigned type such as u32 or usize, but using an integer type opens up new possibilities such as negative weight objects like a balloon, or magic objects that make containers larger. We'll use isize to open up new possibilities.

Adding Attributes - Step 2 - Adding the attributes to the game

The second step in adding the new attributes is to get them into the game code. With many languages this can be a particularly error prone process. Forgetting to add an attribute to a structure, function definition, save format, etc. are all opportunities for errors to enter into the program. Fortunately for us, Rust helps with this sort of change. Rust's type system and definition requirements mean that the compiler will help us do almost everything we need to do to get the new attributes into the game code. All we have to do is pick a place to start, add the attributes, and just keep fixing compiler errors until there aren't any more. Depending on your build environment, you might not even have to explicitly re-compile, your editor will highlight errors for you as it finds them. Lets get started.

To begin, we'll add the attributes to the Object struct:

#[derive(Serialize, Deserialize, Debug)]
pub struct Object {
    pub labels: Vec<String>,
    pub description: String,
    pub location: Option<usize>,
    pub destination: Option<usize>,
    pub prospect: Option<usize>,
    pub details: String,
    pub contents: String,
    pub text_go: String,
    pub weight: isize,
    pub capacity: isize,
    pub health: isize,
}

Explanation
7-13 - Newly added attributes

Next compile the game and find a list of errors to fix.

$ cargo build
   Compiling reentry v0.1.0 (/home/rskerr/dev/reentry)
error[E0063]: missing fields `capacity`, `contents`, `details` and 4 other fields in initializer of `Object`
   --> src/rlib.rs:142:17
    |
142 |                 Object {
    |                 ^^^^^^ missing `capacity`, `contents`, `details` and 4 other fields

error[E0063]: missing fields `capacity`, `contents`, `details` and 4 other fields in initializer of `Object`
   --> src/rlib.rs:148:17
    |
148 |                 Object {
    |                 ^^^^^^ missing `capacity`, `contents`, `details` and 4 other fields

..snip..

This first batch is from the World::new() function which provides literal definitions of game object structs. These are not needed any more since we have the game file. We can delete all the definitions and leave an empty vec! definition in the new() function.

impl World {
    pub fn new() -> Self {
    	World { objects: vec![] }
    }
    // --snip--
}

And rinse and repeat the compile

$ cargo build
   Compiling reentry v0.1.0 (/home/rskerr/dev/reentry)
error[E0063]: missing fields `capacity`, `contents`, `details` and 4 other fields in initializer of `Object`
   --> src/rlib.rs:531:9
    |
531 |         Object {
    |         ^^^^^^ missing `capacity`, `contents`, `details` and 4 other fields

For more information about this error, try `rustc --explain E0063`.
error: could not compile `reentry` due to previous error

This error is from the definition of the Object implementation of its new() function. This is an easy fix to add the new fields.

impl Object {
    fn new(
        new_labels: Vec<String>,
        new_description: String,
        new_location: Option<usize>,
        new_destination: Option<usize>,
        new_prospect: Option<usize>,
        new_details: String,
        new_contents: String,
        new_text_go: String,
        new_weight: isize,
        new_capacity: isize,
        new_health: isize,
    ) -> Object {
        Object {
            labels: new_labels,
            description: new_description,
            location: new_location,
            destination: new_destination,
            prospect: new_prospect,
            details: new_details,
            contents: new_contents,
            text_go: new_text_go,
            weight: new_weight,
            capacity: new_capacity,
            health: new_health,
        }
    }
}

And again, rinse and repeat the compile

$ cargo build
   Compiling reentry v0.1.0 (/home/rskerr/dev/reentry)
error[E0061]: this function takes 11 arguments but 4 arguments were supplied
   --> src/rlib.rs:594:34
    |
594 |               let mut new_object = Object::new(
    |  __________________________________^^^^^^^^^^^-
595 | |                 item.labels.clone(),
596 | |                 item.description.to_string(),
597 | |                 None,
598 | |                 None,
599 | |             );
    | |_____________- multiple arguments are missing
    |
note: associated function defined here
   --> src/rlib.rs:525:8
    |
525 |     fn new(
    |        ^^^
526 |         new_labels: Vec<String>,
    |         -----------------------
527 |         new_description: String,
    |         -----------------------
528 |         new_location: Option<usize>,
    |         ---------------------------
529 |         new_destination: Option<usize>,
    |         ------------------------------
530 |         new_prospect: Option<usize>,
    |         ---------------------------
531 |         new_details: String,
    |         -------------------
532 |         new_contents: String,
    |         --------------------
533 |         new_text_go: String,
    |         -------------------
534 |         new_weight: isize,
    |         -----------------
535 |         new_capacity: isize,
    |         -------------------
536 |         new_health: isize,
    |         -----------------
help: provide the arguments
    |
594 |             let mut new_object = Object::new(item.labels.clone(), item.description.to_string(), None, None, /* std::option::Option<usize> */, /* std::string::String */, /* std::string::String */, /* std::string::String */, /* isize */, /* isize */, /* isize */);
    |                                  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

For more information about this error, try `rustc --explain E0061`.
error: could not compile `reentry` due to previous error

Here I just need to pause and comment on that error message from the compiler. That message is a thing of beauty. Not only does it show the exact code that created the error it highlights the specific place in the code that caused the error. It also shows the source of definition that caused the error (i.e. the definition of the Object::new() function WITH its full definition, tells you how to fix it (as "help: provide the arguments"), and even provides a path to get more information (as "For more information about this error, try `rustc --explain E0061`."). It is in messages like this that I am reminded just how much care the Rust team has taken with tooling and why the language is such a joy to work in.

After that aside, the correction is to review this usage and fix the issues that we find. This error is in the call to Object::new() in the TryInto() implementation. While we're there, we can adjust the rest of the function as well to account for the new parameters.

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,
                None,
                item.details.to_string(),
                item.contents.to_string(),
                item.text_go.to_string(),
                item.weight,
                item.capacity,
                item.health,
            );

            let mut found_location: bool = item.location.is_empty();
            let mut found_destination: bool = item.destination.is_empty();
            let mut found_prospect: bool = item.prospect.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 no prospect is given then use the destination
                    if item.prospect.len() == 0 {
                        new_object.prospect = Some(pos);
                        found_prospect = true;
                    }
                }
                if item.prospect == internal_item.labels[0] {
                    new_object.prospect = Some(pos);
                    found_prospect = true;
                }
                if found_location && found_destination && found_prospect {
                    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
                )));
            }

            if !found_prospect {
                return Err(ParseError::UnknownName(format!(
                    "Unknown prospect '{}'",
                    item.prospect
                )));
            }

            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
13 - Value for prospect, use None initially. prospect will get extracted correctly later in this function.
14-19 - Values for other new attributes can be pulled directly.
24 - Added check flag for processing the prospect value
35-39 - prospect is likely to be an uncommon tag in the game file. If no prospect value is given, set prospect equal to the destination value.
41-44 - If a prospect value IS given, then use its value.
45 - If we've seen all three of location, destination, and prospect then there is no need to continue, push this new Object instance into the list of objects.
65-70 - Add an error case if no prospect is found. (This will likely never be executed because of the check on lines 35-39 above.

The code in TryInto() maps from SavedObject to Object structs. The last set of changes fixed the Object references, but opened up a whole new set of issues with the SavedObject struct that need to be corrected. Again, compile and fix.

$ cargo build
   Compiling reentry v0.1.0 (/home/rskerr/dev/reentry)
error[E0609]: no field `details` on type `&SavedObject`
   --> src/rlib.rs:600:22
    |
600 |                 item.details.to_string(),
    |                      ^^^^^^^ unknown field
    |
    = note: available fields are: `labels`, `description`, `location`, `destination`
    
    ..snip..

The references here are in the SavedObject struct. The correction here is once again to add the new fields.

#[derive(Serialize, Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct SavedObject {
    pub labels: Vec<String>,
    pub description: String,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub location: String,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub destination: String,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub prospect: String,
    pub details: String,
    pub contents: String,
    pub text_go: String,
    pub weight: isize,
    pub capacity: isize,
    pub health: isize,
}

Explanation
10-17 - The newly added fields. For prospect we've followed the existing location and destination pattern with Serde field annotations.

We're going to come back to the SavedObject struct again later on. But for now, these changes let us move on to the next set of errors.

Compiling once again, we find a new error in the From function. With the new attributes, the function fails to properly initialize all of the required values in SavedObject. Lets correct that.

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(),
                },
                prospect: match item.prospect {
                    Some(prospect) => value.objects[prospect].labels[0].to_string(),
                    None => "".to_string(),
                },
                details: item.details.to_string(),
                contents: item.contents.to_string(),
                text_go: item.text_go.to_string(),
                weight: item.weight,
                capacity: item.capacity,
                health: item.health,
            });
        }

        SavedWorld {
            objects: new_vec_of_objects,
        }
    }
}

Explanation
17-26 - The newly added fields.

Take a breath, you've earned it. That is the last of the errors. Compiling now returns just a few warnings that we can clean up by removing the definitions of LOC_BRIDGE, LOC_GALLEY, and LOC_CRYOCHAMBER.

With the changes so far, we've added the new attributes to the Object, SavedObject, and updated the From() and TryInto() functions. We're almost done putting the attributes into the game code. BUT, there is a problem. As is, the new attributes will be required attributes in the game file. We want them to be optional. We can accomplish this by adding Serde field annotations to the SavedObject structure so Serde knows how to generate the deserializer code for the structure.

The Serde default annotation causes Serde to call a named function when the field is not defined in the serialized representation. Similarly, the skip_serializing_if annotation causes Serde to skip serializing the field if the named function returns true. In our case, we can create a function that checks to see if the current value in Saved Object is the default value and if so, return true.

The snippet below shows both the function definitions and updated annotations in the SavedObject structure.

// --snip--
const DEF_PROSPECT: &str = "";
const DEF_DETAILS: &str = "You see nothing special.";
const DEF_CONTENTS: &str = "You see";
const DEF_TEXT_GO: &str = "You can't get much closer than this.";
const DEF_WEIGHT: isize = 99;
const DEF_CAPACITY: isize = 0;
const DEF_HEALTH: isize = 0;

pub fn default_prospect() -> String {
    DEF_PROSPECT.into()
}

pub fn is_default_prospect(value: &str) -> bool {
    value == DEF_PROSPECT
}

pub fn default_details() -> String {
    DEF_DETAILS.into()
}

pub fn is_default_details(value: &str) -> bool {
    value == DEF_DETAILS
}

pub fn default_contents() -> String {
    DEF_CONTENTS.into()
}

pub fn is_default_contents(value: &str) -> bool {
    value == DEF_CONTENTS
}

pub fn default_text_go() -> String {
    DEF_TEXT_GO.into()
}

pub fn is_default_text_go(value: &str) -> bool {
    value == DEF_TEXT_GO
}

pub fn default_weight() -> isize {
    DEF_WEIGHT
}

pub fn is_default_weight(value: &isize) -> bool {
    *value == DEF_WEIGHT
}

pub fn default_capacity() -> isize {
    DEF_CAPACITY
}

pub fn is_default_capacity(value: &isize) -> bool {
    *value == DEF_CAPACITY
}

pub fn default_health() -> isize {
    DEF_HEALTH
}

pub fn is_default_health(value: &isize) -> bool {
    *value == DEF_HEALTH
}

// --snip--

#[derive(Serialize, Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct SavedObject {
    pub labels: Vec<String>,
    pub description: String,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub location: String,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub destination: String,
    #[serde(
        default = "default_prospect",
        skip_serializing_if = "is_default_prospect"
    )]
    pub prospect: String,
    #[serde(
        default = "default_details",
        skip_serializing_if = "is_default_details"
    )]
    pub details: String,
    #[serde(
        default = "default_contents",
        skip_serializing_if = "is_default_contents"
    )]
    pub contents: String,
    #[serde(
        default = "default_text_go",
        skip_serializing_if = "is_default_text_go"
    )]
    pub text_go: String,
    #[serde(default = "default_weight", skip_serializing_if = "is_default_weight")]
    pub weight: isize,
    #[serde(
        default = "default_capacity",
        skip_serializing_if = "is_default_capacity"
    )]
    pub capacity: isize,
    #[serde(default = "default_health", skip_serializing_if = "is_default_health")]
    pub health: isize,
}

Explanation
2-8 - Default values for the new attributes. Set as const so we don't have 'magic' values in code.
10-16, 18-24, 26-32, 34-40, 42-48, 50-56, 58-64 - Definitions of pairs of default_<attribute> and is_default_<attribute>.
77-80, 82-85, 87-90, 92-95, 97, 99-102, 104 - Serde annotations that set and call to check for default values.

And that is it! The new attributes are now fully integrated into the game and available to our game code for use. But they do not do anything yet. We can add the new attributes to the game file, but they won't do anything. We'll deal with that in the next section.

Adding Attributes - Step 3 - Making them work

The next step in the process is to modify the game code to enable the new attributes to have effect. In this step, We'll review the changes for each of the attributes

Details

First up is details in the do_look() function. We'll modify the do_look() function to process the noun in the command and depending on the distance to the object return an appropriate response.

impl World {
    // --snip--
    pub fn do_look(&self, noun: &str) -> String {
        match noun {
            "around" | "" => {
                let (list_string, _) =
                    self.list_objects_at_location(self.objects[LOC_PLAYER].location.unwrap());
                format!(
                    "{}\nYou are in {}.\n",
                    self.objects[self.objects[LOC_PLAYER].location.unwrap()].labels[0],
                    self.objects[self.objects[LOC_PLAYER].location.unwrap()].description
                ) + list_string.as_str()
            }
            _ => {
                let (output_vis, obj_opt) = self.get_visible("what you want to look at", noun);
                let player_to_obj = self.get_distance(Some(LOC_PLAYER), obj_opt);

                match (player_to_obj, obj_opt) {
                    (Distance::HereContained, _) => {
                        output_vis + "Hard to see, you should try to get it first.\n"
                    }
                    (Distance::OverThere, _) => output_vis + "Too far away, move closer please.\n",
                    (Distance::NotHere, _) => {
                        output_vis + &format!("You don't see any {} here.\n", noun)
                    }
                    (Distance::UnknownObject, _) => output_vis,
                    (Distance::Location, Some(obj_idx)) => {
                        let (list_string, _) = self
                            .list_objects_at_location(self.objects[LOC_PLAYER].location.unwrap());
                        output_vis
                            + &format!("{}\n{}\n", self.objects[obj_idx].details, list_string)
                    }
                    (_, Some(obj_idx)) => {
                        let (list_string, _) =
                            self.list_objects_at_location(self.objects[obj_idx].location.unwrap());
                        output_vis
                            + &format!("{}\n{}\n", self.objects[obj_idx].details, list_string)
                    }
                    (_, None) => {
                        // Should never be here
                        output_vis + "How can you look at nothing?.\n"
                    }
                }
            }
        }
    }
    // --snip--
}

Explanation
4 - Match on the noun.
14-44 - Modified arm of the match.
15-16 - Call get_visible() to get the visibility of the noun and then get the distance from the player to the object using get_distance().
18 - Match on the distance from the player.
19-38 - Arms for each of the distances that an object can be. Respond appropriately for each.
39-42 - This arm should never be matched. Unknown objects will be matched on 25, and other known objects will be matched on 32.

Contents

Next we'll add contents. For contents we want the text of the contents string to be displayed instead of the current "You see". This change is in the list_objects_at_location() function.

impl World {
    // --snip--
    
    pub fn list_objects_at_location(&self, location: usize) -> (String, i32) {
        let mut output = String::new();
        let mut count: i32 = 0;
        for (pos, object) in self.objects.iter().enumerate() {
            if pos != LOC_PLAYER && self.is_holding(Some(location), Some(pos)) {
                if count == 0 {
                    output = output + &format!("{}:\n", self.objects[location].contents);
                }
                count += 1;
                output = output + &format!("{}\n", object.description);
            }
        }
        (output, count)
    }
    // --snip--
}

Explanation
10 - Updated the output string to show the contents string.

Text_go

Next up is text_go. For text_go we'll modify do_go() to replace the previous fixed "OK" response with the string in text_go on the passage object.

impl World {
    // --snip--
    
    fn move_player(&mut self, obj_opt: Option<usize>) -> String {
        let go_string = format!("{}\n", self.objects[obj_opt.unwrap()].text_go);
        let obj_dst = obj_opt.and_then(|a| self.objects[a].destination);
        if obj_dst != None {
            self.objects[LOC_PLAYER].location = obj_dst;
            go_string + "\n" + &self.do_look("around")
        } else {
            go_string
        }
    }

    pub fn do_go(&mut self, noun: &str) -> String {
        let (output_vis, obj_opt) = self.get_visible("where you want to go", noun);

        match self.get_distance(Some(LOC_PLAYER), obj_opt) {
            Distance::OverThere => self.move_player(obj_opt),
            Distance::NotHere => {
                format!("You don't see any {} here.\n", noun)
            }
            Distance::UnknownObject => output_vis,
            _ => self.move_player(obj_opt),
        }
    }
}

Explanation
4-13 - New function move_player(). Uses text_go base string. If obj_opt is a passage with a destination, then moves the player to the new location, and then prints the text_go string followed by an automatic 'look around' command. For non-passages, just print the text_go string.
19 - Modifed match arm in do_go() that calls move_player().
23-24 - Modifed match arm in do_go() that calls move_player().

Weight and Capacity

We implement weight and capacity with a new function weight_of_contents() (which calculates the total weight of items in a container), and updates to move_object() that call the new method.

impl World {
    // --snip--

    fn weight_of_contents(&self, container: usize) -> isize {
        let mut sum: isize = 0;
        for (pos, object) in self.objects.iter().enumerate() {
            if self.is_holding(Some(container), Some(pos)) {
                sum += object.weight;
            }
        }
        sum
    }

	// --snip--
    
    pub fn move_object(&mut self, obj_opt: Option<usize>, to: Option<usize>) -> String {
        let obj_loc = obj_opt.and_then(|a| self.objects[a].location);

        match (obj_opt, obj_loc, to) {
            (None, _, _) => String::new(),
            (Some(_), _, None) => "There is nobody to give that to.\n".to_string(),
            (Some(_), None, Some(_)) => "That is way too heavy.\n".to_string(),
            (Some(obj_idx), Some(_), Some(to_idx))
                if self.objects[obj_idx].weight > self.objects[to_idx].capacity =>
            {
                "That is way too heavy.\n".to_string()
            }
            (Some(obj_idx), Some(_), Some(to_idx))
                if self.objects[obj_idx].weight + self.weight_of_contents(to_idx)
                    > self.objects[to_idx].capacity =>
            {
                "That would become to heavy.\n".to_string()
            }
            (Some(obj_idx), Some(_), Some(to_idx)) => {
                let output = self.describe_move(obj_opt, to);
                self.objects[obj_idx].location = Some(to_idx);
                output
            }
        }
    }

	// --snip--
}

Explanation
4-12 - New function weight_of_contents(). Loops through the objects vector and sums the weight of contained objects (i.e. those whose location equals the container index.)
23-27, 28-33 - New match arms that consider weight and capacity. Notice that they use the same match tuple (i.e. (Some(obj_idx), Some(_), Some(to_idx))) but these arms have if guards that catch matches where the object is too heavy, or would become too heavy, respectively.

Health

We implement health checks by replacing the explicit checks for actors (e.g. LOC_COPILOT) with a check on health greater than zero. These changes generalize the functions so they work with any actor rather than just the named actor. The changes for health checks are found in describe_move(), do_get(), and actor_here(). After these changes, we can also remove the definition of LOC_COPILOT as it is no longer needed.

// --snip--
// const LOC_COPILOT = 7;
// --snip--

impl World {
    // --snip--
    pub fn describe_move(&self, obj_opt: Option<usize>, to: Option<usize>) -> String {
        let obj_loc = obj_opt.and_then(|a| self.objects[a].location);
        let player_loc = self.objects[LOC_PLAYER].location;

        match (obj_opt, obj_loc, to, player_loc) {
            // --snip--
            (Some(obj_opt_idx), _, Some(to_idx), _) if to_idx != LOC_PLAYER => {
                if self.objects[to_idx].health > 0 {
                    format!(
                        "You give {} to {}.\n",
                        self.objects[obj_opt_idx].labels[0], self.objects[to_idx].labels[0]
                    )
                } else {
                    format!(
                        "You put {} in {}.\n",
                        self.objects[obj_opt_idx].labels[0], self.objects[to_idx].labels[0]
                    )
                }
            }
            // --snip--
        }
    }

    // --snip--

    pub fn do_get(&mut self, noun: &str) -> String {
        let (output_vis, obj_opt) = self.get_visible("what you want to get", noun);

        let player_to_obj = self.get_distance(Some(LOC_PLAYER), obj_opt);

        match (player_to_obj, obj_opt) {
            // --snip--
            _ => {
                let obj_loc = obj_opt.and_then(|a| self.objects[a].location);

                if obj_loc.is_some() && self.objects[obj_loc.unwrap()].health > 0 {
                    output_vis
                        + &format!(
                            "You should ask {} nicely.\n",
                            self.objects[obj_loc.unwrap()].labels[0]
                        )
                } else {
                    self.move_object(obj_opt, Some(LOC_PLAYER))
                }
            }
        }
    }
    
    // --snip--
    
    pub fn actor_here(&self) -> Option<usize> {
        let mut actor_loc: Option<usize> = None;

        for (pos, object) in self.objects.iter().enumerate() {
            if self.is_holding(self.objects[LOC_PLAYER].location, Some(pos))
                && pos == LOC_PLAYER
                && object.health > 0
            {
                actor_loc = Some(pos);
            }
        }
        actor_loc
    }
    // --snip--
}

Explanation
2 - Remove LOC_COPILOT
14 - Replace check for LOC_COPILOT with check for health > 0
42-48 - In the default match arm body, replace the check for LOC_COPILOT with a health check. Also in the body, replace the response text with responsive text that uses the actor's label.
61-63 - Generalize the check for an actor in actor_here() to work on any actor with health greater than zero.

Prospect

The last attribute to add in is prospect. Prospect changes the apparent destination of a passage and allows passages with a 'twist' that take the player someplace other than what is indicated. The change here is to get_passage_index(). This will modify the response to all commands (not just go and look) that are at the end of a 'twisted' passage.

impl World {
    // --snip--
    fn get_passage_index(&self, from_opt: Option<usize>, to_opt: Option<usize>) -> Option<usize> {
        let mut result: Option<usize> = None;

        if from_opt.is_some() && to_opt.is_some() {
            for (pos, object) in self.objects.iter().enumerate() {
                if self.is_holding(from_opt, Some(pos)) && object.prospect == to_opt {
                    result = Some(pos);
                    break;
                }
            }
            result
        } else {
            result
        }
    }
    // --snip--
}

Explanation
8 - Update the check for passages to check on prospect instead of destination.

Adding Attributes - Step 3 - Updates to the game file

The last step is to update the game file to take advantage of the new attributes that we've added. We'll add new attributes (e.g. details, weight, etc.) to existing objects. We'll also add a new location 'Outside' to highlight the 'prospect' attribute, and a new object 'Table' to highlight the 'weight' attribute.

//
// Reentry
//
// --snip--
World (
    objects : [
        (labels     : ["Bridge"],
        description : "the bridge",
        details     : "The bridge surrounds you. From here you can control all ship operations. Dials and blinking lights cover the walls.",
        capacity    : 9999,
        ),
        // --snip--
        (labels     : ["Outside"],
        description : "the vacuum of space",
        details     : "Outside, the vacuum of space extends to vast inky darkness. Points of light from distant stars dot the view.",
        capacity    : 9999,
        ),
        (labels     : ["Yourself"],
        description : "yourself",
        location    : "Bridge",
        details     : "You look down at yourself and see coveralls worn from years of use. A nametag on your chest reads 'Woods.' Above the tag a second label bears the letters 'XO.'",
        capacity    : 20,
        ),
        // --snip--
        (labels     : ["Table"],
        description : "a large square table",
        location    : "Galley",
        details     : "The table is a large square surface about waist high. The top is a plain light green. The top is worn from long use. Scratches and nicks cover its surface",
        weight      : 25,
        ),
        // --snip--
        (labels     : ["Aft", "airlock"],
        description : "an airlock aft to exit the ship",
        location    : "Cryochamber",
        destination : "Cryochamber",
        prospect    : "Outside",
        details     : "The airlock leads outside and the vastness of space.",
        text_go     : "Through the airlock lies certain death. Surely there is still hope.",
        ),
        (labels     : ["Forward", "cryochamber"],
        description : "an airlock into the ship",
        location    : "Outside",
        destination : "Cryochamber",
        details     : "The airlock leads into the interior of he ship.",
        text_go     : "Through the cramped airlock the cryochamber opens before you.",
        ),
        // --snip--
    ]
)

Running the game now will cause a panic because we've unintentionally created a dependency in the game on the location of the player. The problem lies with the constant LOC_PLAYER and the game file. The constant identifies the index into the objects array of the Object instance that represents the player. It is currently defined as '3'. When we added the new location 'Outside', it shifted the player's location and all of the places in the code that use the constant now became wrong.

We can correct the issue by adjusting the index, but we do not want to have to keep adjusting the constant as we add different Objects to the game. A slightly better way is to shift the player Object to the front of the array and updating the constant in the game. (Note, this is still not perfect as the dependency is still there, but is minimized by the convention of putting the player as the first object in the game. The change is simple though and should be good enough for our purpose.

const LOC_PLAYER: usize = 0;

In the game file, we just need to shift the player object 'Yourself' to the first entry and everything will work as expected.

Progress

Adding these new parameters gives the game much more depth. The details attribute in particular makes the game objects much more interesting and allows opportunities for us to place clues in descriptions as needed to help with game puzzles. Similarly attributes like weight and text_go allow us to create new puzzles for the players as needed. We could stop here, but there is one more thing that we can do.

Bonus - Maps

As the game grows, being able to understand the locations and objects in the game will become increasingly challengine. Particularly now that we've added the 'prospect' attribute. One way that we can make editing the game easier is with maps. We can use awk and dot to create graphical maps of the game as described in the game file.

BEGIN     { print "digraph map {"; }
/^[ \t]*\(/     { outputEdges(); delete a; }
/^[ \t]/  { gsub(/"/, "", $3); gsub(/,/, "", $3); a[$1] = $3; }
END       { outputEdges(); print "}"; }
function outputEdges()
{
   outputEdge(a["location"], a["destination"], "");
   outputEdge(a["location"], a["prospect"], " [style=dashed]");
}

function outputEdge(from, to, style)
{
   if (from && to) print "\t" from " -> " to style;
}

Explanation
1 - Begin the output of a dot file
2 - At the start of every Object in game_file.ron, print the output for the previous Object, then clear the attributes collection.
3 - Add attributes for the Objects as discovered on each line.
4 - At the end, print the last object, and finish the dot file output.

We can use this file by first calling awk and then dot. (You may need to install these. You probably have awk, but dot might not be available unless installed.)

$ awk -f map.awk game_file.ron > map.gv
$ dot -Tpng -o map.png map.gv

Running these commands should produce an output file 'map.png' that can be visualized using any image viewer.

A graphical presentation of the reentry game map. Shows locations as ovals connected by arrows that show the passages between locations.

Wrapping up

In the next post, we'll have a look at conditions. Conditions will allow the game to create Objects and events in the environment that only occur when certain things are true.

⇂  View Source

Enjoyed this post? Help me create more with a coffee. Never miss out on future posts by following me.

Comments

Popular posts from this blog

How to make a Text Adventure game in Rust - Introduction