How to Make a Text Adventure game in Rust - III - Locations

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

3 - Locations

Text adventures transport players to a new world. These worlds are built from locations that form the backdrop of the story of the game. Typically locations represent real world locations such as a path or a cave or a starship bridge and cabin. Locations are connected using paths that form both obvious (e.g. north, south, in, out, etc.) and non-obvious (e.g. loops, one way links, etc.) connections.




In this post, we'll add locations to our game and allow the player to move between them. We'll add connections between locations in a later post. For now, players will be able to jump between locations at will using the location name.

The Location Struct

To start, we'll define a struct type to represent a location. Initially the location struct will have just two attributes - name, and description:
pub struct Location {
    pub name: String,
    pub description: String,
}

Explanation
1 - Define the Location struct.
2 - name: the name of the location. The parser will use this to know if a player is navigating to a location.
3 - description: a description of the location. This is where we'll put narrative text that describes a location and brings it to life.

To make locations useful, we need two more things. First, we need a place to hold a set of Location structs. We can use a Rust Vec() for that. (Note: A Hashmap might also be a good choice here. I've opted for a Vec() because it feels more natural to me.) We also need a place to store the location of the player. This place will hold an index into the Vec() that lets us know where the player is at any time. Since these two variables work together and will need to be accessed together, we'll put them in a shared struct World.
pub struct World {
    pub player_location: usize,
    pub locations: Vec<Location>,
}

Explanation
1 - Define the World struct.
2 - player_location: the index in the locations Vec() of the player's current location.
3 - locations: a Vec() of  location structs that describe each location. Note that the Vec() is templatized for Location structs.

Next we can create some locations. The pattern in Rust for initialization is to define a new() function implementation for a structure. The sample below shows the implementation for new() on the World struct.

impl World {
    pub fn new() -> Self {
        World {
            player_location: 0,
            locations: vec![
                Location {
                    name: "Bridge".to_string(),
                    description: "the bridge".to_string(),
                },
                Location {
                    name: "Galley".to_string(),
                    description: "the galley".to_string(),
                },
                Location {
                    name: "Cryochamber".to_string(),
                    description: "the cryochamber".to_string(),
                },
            ],
        }
    }
    
    // ....
}

Explanation
1 - Implementation for the World struct.
2-20 - The new() function.
4 - Initialize player_location to the first location.
5 - Initialize the locations Vec() with 3 locations
6-9, 10-13, 14-17 - The three locations, each containing a name and description.

We can use the descriptions in a location with a println!() macro. This statement:

println!("You are in {}.", world.locations[0].description)
will print the following to the screen:
"You are in the bridge."

Note, in much of the game code below we use the format!() macro instead of println!(). format!() returns output to a string rather than directly to the screen.

Implementing Locations

To implement the enhancements we start with the update_state() function. Previously, the update_state() function was a stand alone function. For the change here, we move update_state() to an associated function (i.e. impl) on the World struct. Secondly, we modify the arms of the contained match to process the commands.
impl World {
    // ... fn new()

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

// Deleted the earlier implementation of update_state()

Explanation
4-11 - The modified update_state() function.
6 - Look arm - Matches on Command::Look, destructures the noun, and calls do_look().
7 - Go arm - Matches on Command::Go,destructures the noun, and calls do_go().
16 - The prior implementation of update_state() is deleted as it is no longer needed.

The match in update_state() calls two new functions - do_look() and do_go(). These functions need access to player_location and the locations Vec(), so they are also implemented as associated functions to the World struct. do_look() is implemented as a simple match on the passed noun that checks if the value is "around" or "" (in which case we assume the player meant "around"). All other nouns are not understood.

do_go() is a bit more complicated. Its implementation loops through each of the locations to see if the noun is the name of an existing location. If it is, it moves the player there and calls do_look(). Otherwise, do_go() returns the string "I don't understand where you want to go."
impl World {
    // ... fn new()
    // ... fn update_state()
    
    pub fn do_look(&self, noun: &String) -> String {
        match noun.as_str() {
            "around" | "" => format!(
                "{}\nYou are in {}.\n",
                self.locations[self.player_location].name,
                self.locations[self.player_location].description
            ),
            _ => format!("I don't understand what you want to see.\n"),
        }
    }

    pub fn do_go(&mut self, noun: &String) -> String {
        let mut output = String::new();

        for (pos, location) in self.locations.iter().enumerate() {
            if *noun == location.name.to_lowercase() {
                if pos == self.player_location {
                    output = output + &format!("Wherever you go, there you are.\n");
                } else {
                    self.player_location = pos;
                    output = output + &format!("OK.\n\n") + &self.do_look(&"around".to_string());
                }
                break;
            }
        }

        if output.len() == 0 {
            format!("I don't understand where you want to go.")
        } else {
            output
        }
    }
}

Explanation
5-14 - The do_look() function.
6 - match on the noun
7 - "around" or "" noun values
7-11 - Display the name and description of the location
12 - Display 'I don't understand' message for all other noun values
16-36 - The do_go() function.
17 - Define output string that will hold printed messages to send to the screen.
19-29 - Loop over each Location in location and if the location matches the noun, either move the player to the new location and do a 'look,' or print a message that says 'you're already there.' Break out of the loop if we've found the correct location.
31-35 - Handle the case where the location wasn't found. Either way return a string.

We're almost finished. The last thing to do is to add the World struct to our main function so we can use it in the game.
pub mod rlib;

fn main() {
    //
    // Introduction and Setup
    //
    println!("Welcome to Reentry. A space adventure.");
    println!("");
    println!("You awake in darkness with a pounding headache.");
    println!("An alarm is flashing and beeping loudly. This doesn't help your headache.");
    println!("");

    let mut command: rlib::Command;
    let mut world = rlib::World::new();
    let mut output: String;

    //
    // Main Loop
    //
    loop {
        command = rlib::get_input();
        output = world.update_state(&command);
        rlib::update_screen(output);

        if matches!(command, rlib::Command::Quit) {
            break;
        }
    }

    //
    // Shutdown and Exit
    //
    println!("Bye!");
}

Explanation
14 - Create a new mutable instance of the World struct and call new() to initialize the struct.
22 - Call update_state() as an implementation function on World. This passes the world variable as &self in the update_state() function.

Progress

Adding these changes changes interaction with our game as shown in the output sample below. Players can now move around using 'go <location>' and look at different parts of the environment using 'look around'. Even this simple change raises the level of interest in the game substantially because it allows players to explore the world.

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 around
Bridge
You are in the bridge.

> go galley
OK.

Galley
You are in the galley.

> go cryochamber
OK.

Cryochamber
You are in the cryochamber.

> look
Cryochamber
You are in the cryochamber.

> quit
Quitting.
Thank you for playing!
Bye!

The example here shows very simple descriptions but a good writer (which I am not) could use just this feature alone to create a compelling story that players 'discover' by navigating around and learning about their environment as they go.

⇂  View Source

Feel free to pull down the code and experiment. Can you add more locations? Better descriptions? Tell a story even?

In the next post, we'll add things for the player to interact with.  Knives, space suits, and aliens await.

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

Comments

  1. Is this: "3 - locations: a description of the location. This is where we'll put narrative text that describes a location and brings it to life." intentional? it looks like should be "list of location with name/description"

    ReplyDelete

Post a Comment

Popular posts from this blog

How to make a Text Adventure game in Rust - Introduction