How to make a Text Adventure game in Rust - V - Inventory

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

5 - Inventory

In the last post we added items and actors to the game by converting the locations into objects and treating everything (locations, items, actors, even the player!) as an object. This shift opens up new possibilities for the game because items can be locations, locations can be actors, etc. Even more importantly, the player, an actor, is also a location (that can hold things). As an actor the player has a location as well. Taken together, these make common actions very easy to implement.
ActionTypical CommandExample
Player moves from location to locationgoobjects[LOC_PLAYER].location == LOC_BRIDGE;
List items and actors present at locationlooklist_objects_at_location(LOC_BRIDGE);
Player gets an itemgetobjects[LOC_PHOTO].location == LOC_PLAYER;
Player drops an itemdropobjects[LOC_PHOTO].location == objects[LOC_PLAYER].location;
List the player's inventoryinventorylist_objects_at_location(LOC_PLAYER);
Player gives an item to an actorgiveobjects[LOC_PHOTO].location == LOC_COPILOT;
Player receives an item from an actoraskobjects[LOC_PHOTO].location == LOC_PLAYER;
List an actor's inventoryexaminelist_objects_at_location(LOC_COPILOT);

We added go and look in the previous post. In this post, we'll add typical inventory actions for the player and non-player actors. We'll add commands for get, drop, give, ask, and inventory.

Add the commands

The first step to add the inventory commands is to add them to the Command enum and modify the parse() function to detect the new commands when called by get_input().
pub enum Command {
    Ask(String),
    Drop(String),
    Get(String),
    Give(String),
    Go(String),
    Inventory,
    Look(String),
    Quit,
    Unknown(String),
}

pub fn parse(input_str: String) -> Command {
    let lc_input_str = input_str.to_lowercase();
    let mut split_input_iter = lc_input_str.trim().split_whitespace();

    let verb = split_input_iter.next().unwrap_or_default().to_string();
    let noun = split_input_iter.next().unwrap_or_default().to_string();

    match verb.as_str() {
        "ask" => Command::Ask(noun),
        "drop" => Command::Drop(noun),
        "get" => Command::Get(noun),
        "give" => Command::Give(noun),
        "go" => Command::Go(noun),
        "inventory" => Command::Inventory,
        "look" => Command::Look(noun),
        "quit" => Command::Quit,
        _ => Command::Unknown(input_str.trim().to_string()),
    }
}

Explanation
2-10 - Updated Command enum. Added values for ask, drop, get, give, and inventory.
21-28 - Updated match in parse() function. Added match arms for askdropgetgive, and inventory.

Process the commands

With the updates to the Command enum, the game can capture player input and parse player input verbs into command values. Next we'll modify update_state() where the game acts on player commands. update_state() needs additional entries to add new do_<action> functions associated with each command. Those changes can be implemented like so:
impl World {
    // --snip--

    pub fn update_state(&mut self, command: &Command) -> String {
        match command {
            Command::Ask(noun) => self.do_ask(noun),
            Command::Drop(noun) => self.do_drop(noun),
            Command::Get(noun) => self.do_get(noun),
            Command::Give(noun) => self.do_give(noun),
            Command::Go(noun) => self.do_go(noun),
            Command::Inventory => self.do_inventory(),
            Command::Look(noun) => self.do_look(noun),
            Command::Quit => format!("Quitting.\nThank you for playing!"),
            Command::Unknown(input_str) => format!("I don't know how to '{}'.", input_str),
        }
    }
    
    // --snip--
}

Explanation
6-14 - Updated match in update_state() function. The added arms for askdropgetgive, and inventory each call a do_<action> function.

Implement the commands

The above changes enable update_state() to call do_<action> functions based on player input. The next step is to write an implementation for each action that updates the game state appropriately based on the command. We'll consider the do_<action> function implementations first. We'll review the helper functions later in this post.
impl World {
    // --snip--

    pub fn do_ask(&mut self, noun: &String) -> String {
        let actor_loc = self.actor_here();
        let (output, object_idx) =
            self.get_possession(actor_loc, Command::Ask("ask".to_string()), noun);

        output + self.move_object(object_idx, Some(LOC_PLAYER)).as_str()
    }

    pub fn do_give(&mut self, noun: &String) -> String {
        let actor_loc = self.actor_here();
        let (output, object_idx) =
            self.get_possession(Some(LOC_PLAYER), Command::Give("give".to_string()), noun);

        output + self.move_object(object_idx, actor_loc).as_str()
    }

    pub fn do_drop(&mut self, noun: &String) -> String {
        let (output, object_idx) =
            self.get_possession(Some(LOC_PLAYER), Command::Drop("drop".to_string()), noun);
        let player_loc = self.objects[LOC_PLAYER].location;

        output + self.move_object(object_idx, player_loc).as_str()
    }


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

        let obj_loc = obj_opt.and_then(|a| self.objects[a].location);

        match (obj_opt, obj_loc) {
            (None, _) => output_vis,
            (Some(object_idx), _) if object_idx == LOC_PLAYER => {
                output_vis + &format!("You should not be doing that to yourself.\n")
            }
            (Some(object_idx), Some(obj_loc)) if obj_loc == LOC_PLAYER => {
                output_vis
                    + &format!(
                        "You already have {}.\n",
                        self.objects[object_idx].description
                    )
            }
            (Some(_), Some(obj_loc)) if obj_loc == LOC_COPILOT => {
                output_vis + &format!("You should ask nicely.\n")
            }
            (obj_opt, _) => self.move_object(obj_opt, Some(LOC_PLAYER)),
        }
    }

    pub fn do_inventory(&self) -> String {
        let (list_string, count) = self.list_objects_at_location(LOC_PLAYER);
        if count == 0 {
            format!("You are empty handed.\n")
        } else {
            list_string
        }
    }

    // --snip--
}

Explanation
6-7, 14-15, 21-22, 30 - do_ask(), do_give(), do_drop(), and do_get() each deal with an item. To figure out which item to act on, the functions call get_visible() or get_possession() depending on if the action indicates the item would be held by an actor (e.g. ask, or give to a non-player actor) or at a visible location (e.g. get an object on the ground). We implemented get_visible() in the objects post as part of the implementation of do_go(). We'll look at get_possession() below. For now, just know that get_possession() is checking to see if an object is held by an actor and if so, returning the object index.
9, 17, 25, 49 - do_ask(), do_give(), do_drop(), and do_get() all use the helper function move_object() to update the objects vec(). The implementations for each of the actions is essentially to setup the call to move_object() and then make the call. do_get() includes some additional checks to accommodate cases where the player attempts to get something that they should not be able to (e.g. getting a location, or getting an object held by another actor).
5, 13 - The game's parser currently only allows two word commands. One for the verb and one for the noun. However, ask and give need to know not just what is being acted on (i.e. the noun), but with whom. do_ask() and do_give() use the helper function actor_here() that identifies the actor (if any) at the current location that the commands should act on.
36, 39, 46 - do_get() includes checks to stop the player from attempting to get themselves, get something they already have, or get something held by another actor, respectively.
53-59 - do_inventory() uses the same list_objects_at_location() function that the game uses to show objects at a location, just with the location being the player! This an example of the benefit of having the player be a game Object like any other.

Moving Objects

Lets look now at the implementation of move_object(). The game calls move_object() to update the objects vec() and change the location of various objects.
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) {
            (Some(obj_opt_idx), _, Some(to_idx), Some(player_loc_idx))
                if to_idx == player_loc_idx =>
            {
                format!("You drop {}.\n", self.objects[obj_opt_idx].name)
            }
            (Some(obj_opt_idx), _, Some(to_idx), _) if to_idx != LOC_PLAYER => {
                if to_idx == LOC_COPILOT {
                    format!(
                        "You give {} to {}.\n",
                        self.objects[obj_opt_idx].name, self.objects[to_idx].name
                    )
                } else {
                    format!(
                        "You put {} in {}.\n",
                        self.objects[obj_opt_idx].name, self.objects[to_idx].name
                    )
                }
            }
            (Some(obj_opt_idx), Some(obj_loc_idx), _, Some(player_loc_idx))
                if obj_loc_idx == player_loc_idx =>
            {
                format!("You pick up {}.\n", self.objects[obj_opt_idx].name)
            }
            (Some(obj_opt_idx), Some(obj_loc_idx), _, _) => format!(
                "You get {} from {}.\n",
                self.objects[obj_opt_idx].name, self.objects[obj_loc_idx].name
            ),
            // This arm should never get hit.
            (None, _, _, _) | (_, None, _, _) => format!("How can you drop nothing?.\n"),
        }
    }

    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, _, _) => format!(""),
            (Some(_), _, None) => format!("There is nobody to give that to.\n"),
            (Some(_), None, Some(_)) => format!("That is way too heavy.\n"),
            (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
41-54 - The move_object() function. move_object() takes as input the object being moved (obj_opt) and the location it should be moved to (to).
44 - match to select the correct move action.
45 - No object given (obj_opt = None). This is an error. Do not move anything.
46 - Object given, but no to given (to = None). Return an appropriate message for the player.
47 - Object given, but the object has no location (i.e. it is a location). Return an appriate message to the player.
48 - Both an object and a to location have been passed. Create an appropriate message with describe_move(), move the object, and return the message as output.
4-39 - The describe_move() function. describe_move() takes as input the object being moved (obj_opt) and the location it should be moved to (to).
8 - match to select the correct move message.
9 - Is the to location the same as the player's location? This is a drop. Return an appropriate message for drop.
14 - Is the to location the same as an actor? This is a give. Return an appropriate message for give.
27 - Is the object location the same as the player's location? This is a get. Return an appropriate message for get.
32 - Is the object location the same as some other object location? This is a retreival from an object. Return an appropriate message for a get from a location.
37 - Required by Rust for completeness. This arm should never be hit.

Possessions

For the go and get commands the game uses get_visible() to determine if the passed noun is accessible at a location. Similarly, when implementing inventory commands that interact with other actors (i.e. ask, give, and drop) the game needs a function that can determine if the object is held (or can be held) by the actor at the location. get_possession() does just that.
impl World {
    // --snip--

    pub fn get_possession(
        &mut self,
        from: Option<usize>,
        command: Command,
        noun: &String,
    ) -> (String, Option<usize>) {
        let object_idx = self.get_object_index(noun);
        let object_loc = object_idx.and_then(|a| self.objects[a].location);

        match (from, object_idx, object_loc) {
            (None, _, _) => (
                format!("I don't understand what you want to {}.\n", command),
                None,
            ),
            (Some(_), None, _) => (
                format!("I don't understand what you want to {}.\n", command),
                None,
            ),
            (Some(from_idx), Some(object_idx), _) if object_idx == from_idx => (
                format!(
                    "You should not be doing that to {}.\n",
                    self.objects[object_idx].name
                ),
                None,
            ),
            (Some(_), Some(object_idx), None) => (
                format!("You can't do that to {}.\n", self.objects[object_idx].name),
                None,
            ),
            (Some(from_idx), Some(object_idx), Some(object_loc_idx))
                if object_loc_idx != from_idx =>
            {
                if from_idx == LOC_PLAYER {
                    (
                        format!(
                            "You are not holding any {}.\n",
                            self.objects[object_idx].name
                        ),
                        None,
                    )
                } else {
                    (
                        format!(
                            "There appears to be no {} you can get from {}.\n",
                            noun, self.objects[from_idx].name
                        ),
                        None,
                    )
                }
            }
            _ => ("".to_string(), object_idx),
        }
    }

    // --snip--
}

Explanation
3-55 - The get_possession() function. Like get_visible(), get_possession() is a wrapper around get_object_index(). get_possession() takes a passed noun as input and returns either the matching Object or None.
12-54 - The match in get_posession() checks conditions to return an appropriate message for the posession.

Actors

The final helper function to implement is actor_here(). actor_here() returns the actor index if an actor is found at the current location, or None otherwise.
impl World {
    // --snip--
    
    pub fn actor_here(&self) -> Option<usize> {
        let mut actor_loc: Option<usize> = None;

        for (pos, object) in self.objects.iter().enumerate() {
            match (pos, object.location) {
                (_, obj_loc)
                    if (obj_loc == self.objects[LOC_PLAYER].location) && (pos == LOC_COPILOT) =>
                {
                    actor_loc = Some(pos);
                    break;
                }
                _ => continue,
            }
        }

        actor_loc
    }
    
    // --snip--
}

Explanation
4-20 - The actor_here() function. actor_here() iterates through each object and checks to see if the object is both at the same location as the player and a known actor (e.g. the copilot).

Progress

Like adding objects, adding inventory actions is a huge step forward for the game. The player is now an agent in the world that can change things by moving objects around and giving and taking objects from other game actors.

⇂  View Source

One more thing - clippy

One of the great features of Rust is tooling. The Rust compiler and surrounding tools like rustfmt and clippy collectively work to make developers better by encouraging good coding practices and the use of idiomatic constructs. During the development of this post, I learned that Clippy was not enabled in my development environment. Enabling clippy showed a number of areas where the text adventure game code could be improved. Rather than go back and correct prior posts and code snippets, I've implemented the changes as a pair of additional commits that clean up the clippy identified issues. Going forward, I'll catch them as they occur.

The most notable changes involved removing empty strings (i.e. "") in println! statements, simplified string handling and removing unnecessary format! calls, and finally the conversion of several String parameters into &str references to eliminate unnecessary construction and destruction of Strings during execution. I've included a link to the source here, so you can see the changes directly if you desire.

⇂  View Source

Next post we'll add passages between game locations. No more will the player be able to jump all over the map!

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