How to make a Text Adventure game in Rust - VIII - Directions
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 |
8 - Directions
Players in text adventures are accustomed to using directions to navigate around their environment. On the ground, players would use the directions North, South, East and West and commands like go east to move from location to location. On a ship or other vessel people would use the nautical terms Foward, Aft, Port and Starboard and commands such as go forward as shown in the figure below.
Using directions like these helps players to form a mental map of how different locations relate to each other and increases immersion by putting the player 'in' the world as they move around. Our goal for this post is to allow players to use cardinal and relative directions to navigate the locations in the game.
To start, lets review the game locations and the navigation that we would like to enable.
We could enable players to use direction by changing the names of the Objects
in the game, but that creates two problems that we'll need to address first.
- First, the player may wish to continue to use place names (e.g. "go galley") as well as directional names (e.g. "go aft"). Right now, locations can only have one name.
- Once we start adding directional names, the names will not be unique anymore. The game does not know how to handle situations where locations might share a name.
This same problem (i.e. that Objects
might have the same name) also applies to items, passages and actors in the game. Currently, the player has a (glossy) photo. But what if the copilot also has a (folded) photo. On the one hand, we would like the game to accept get photo if only one of the photos is accessible, but on the other hand, the game should accept get glossy photo instead if both photos are present at the same location.
This brings us to a third problem with the game's parser:
- A name can only be a single word; "glossy photo" would not be recognized.
We'll address all three of these problems in this post, starting with the first one.
Implementing Directions - Part 1: The Labels Vec
We can allow locations to have multiple place names by giving each Object
a list of names rather than just one. We'll store the names using a Rust vec()
and modify the functions that search based on name to accommodate the use of a vector.
Lets go!
pub struct Object {
pub labels: Vec<String>,
pub description: String,
pub location: Option<usize>,
pub destination: Option<usize>,
}
Explanation
2 - Here we've modified name to be a Vec()
. Notice also that we've renamed name
to the more descriptive labels
.
With this change, we've modified Objects
to include a vector of labels rather than a single name as before. BUT... we've also broken the game. Right now it won't even compile! We need to ripple the name change and vector through the rest of the game implementation. The first place to fix is the definition of the Objects
themselves. Lets look at them below.
impl World {
pub fn new() -> Self {
World {
objects: vec![
Object {
labels: vec!["Bridge".into()],
description: "the bridge".into(),
location: None,
destination: None,
},
Object {
labels: vec!["Galley".into()],
description: "the galley".into(),
location: None,
destination: None,
},
Object {
labels: vec!["Cryochamber".into()],
description: "the cryochamber".into(),
location: None,
destination: None,
},
Object {
labels: vec!["Yourself".into()],
description: "yourself".into(),
location: Some(LOC_BRIDGE),
destination: None,
},
Object {
labels: vec!["Glossy Photo".into(), "Photo".into()],
description: "a glossy photo of a family. They look familiar".into(),
location: Some(LOC_BRIDGE),
destination: None,
},
Object {
labels: vec!["Cryosuit".into()],
description: "a silver suit that will protect you in cryosleep".into(),
location: Some(LOC_CRYOCHAMBER),
destination: None,
},
Object {
labels: vec!["Wrinkled Photo".into(), "Photo".into()],
description: "a wrinkled photo of a woman. They woman is crying".into(),
location: Some(LOC_COPILOT),
destination: None,
},
Object {
labels: vec!["Copilot".into()],
description: "your copilot sleeping in his cryochamber".into(),
location: Some(LOC_CRYOCHAMBER),
destination: None,
},
Object {
labels: vec!["Pen".into()],
description: "a pen".into(),
location: Some(LOC_COPILOT),
destination: None,
},
Object {
labels: vec!["Aft".into()],
description: "a passage aft to the galley".into(),
location: Some(LOC_BRIDGE),
destination: Some(LOC_GALLEY),
},
Object {
labels: vec!["Forward".into()],
description: "a passage forward to the bridge".into(),
location: Some(LOC_GALLEY),
destination: Some(LOC_BRIDGE),
},
Object {
labels: vec!["Aft".into()],
description: "a passage aft to the cryochamber".into(),
location: Some(LOC_GALLEY),
destination: Some(LOC_CRYOCHAMBER),
},
Object {
labels: vec!["Forward".into()],
description: "a passage forward to the galley".into(),
location: Some(LOC_CRYOCHAMBER),
destination: Some(LOC_GALLEY),
},
Object {
labels: vec!["Forward".into(), "Port".into(), "Starboard".into()],
description: "a bulkhead covered in switchpanels and gauges".into(),
location: Some(LOC_BRIDGE),
destination: None,
},
Object {
labels: vec!["Port".into(), "Starboard".into()],
description: "a smooth bulkhead with an endless void on the other side".into(),
location: Some(LOC_GALLEY),
destination: None,
},
Object {
labels: vec!["Aft".into(), "Port".into(), "Starboard".into()],
description:
"cryochambers backed by a dense tangle of pipes, tubes, and conduits".into(),
location: Some(LOC_CRYOCHAMBER),
destination: None,
},
],
}
}
// --snip--
}
Explanation
6,12,18,24,30,36,42,48,54,60,66,72,78 - Here we've renamed the name
attribute and modified it to be a Vec()
. Using vec!()
to declare the vector is a little syntax sugar to make things read a little easier.
30,42 - Some Objects
have multiple labels. We always put the most descriptive label first so we can use that for display to the user in game text.
83-101 - Here we've added passages for any 'walls' that are not other passages. Otherwise go aft would return "You don't see any aft here." We could also have used an actual passage that ends in the start location, but then go aft would result in "OK" as the player traverses the path. This is misleading to the player since they won't have moved anywhere. Using None as a destination instead makes a dummy passage that goes nowhere. The game will still show "You can't get much closer than this," which is perhaps equally misleading, but we'll fix that in a future post.
The final step to ripple the attribute name change from name
to labels
through the game is to find and replace all of the references to Object.name
with Object.labels[0]
to refer to the first element of the labels Vec()
. These changes are distributed throughout the rlib.rs
module, so we won't show all of them, but the segment below shows what that looks like in one example.
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()
}
_ => "I don't understand what you want to see.\n".to_string(),
}
}
// --snip--
}
Explanation
4-17 - Here we're looking at the do_look()
function. This function performs the game actions when the player enters the command look.
11 - This line shows the change from name
to labels[0]
. All of the other replacements in rlib.rs
follow the same pattern as shown here.
Implementing Directions - Part 2: Allowing Multiple Labels
With the change above, we've modified the game so that Objects
can have multiple labels. But game actions still do not actually know how to deal with multiple labels. As is, only the first label could be used by game players. Enabling the full list of labels to be used to refer to an Object
requires changes in object_has_label()
, get_object_index()
, get_visible()
and get_posession()
. Making these adjustments will let us fully resolve issue #1 from our list above.
While we are making changes to these functions we can also address issue #2, that Object
names may no longer be unique. The challenge with issue #2 is dealing with the situation where there are two visible or possessed Objects
that have the same name. In that situation, the game won't know which one to refer to. Consider the example of the two photos in the game. The glossy photo and the wrinkled photo may both be referred to as photo. If these two Objects
are in the same location, the game won't reasonably know which of them the player meant. Since the game cannot know which one, we must inform the player they need to be more specific.
To address the case where the object referenced is not clear, we can modify get_object_index()
to return an indication that the result is ambiguous and then other methods can be modified to handle this case appropriately. get_object_index()
currently returns Option<usize>
where None
indicates that no object is found, and Some(usize)
indicates the index of the found Object
. However, there is no Ambiguous case in the Option
enum. One way to address this would be to return an Option<Option<usize>>
, but this approach is opaque and would require future developers (me?) to remember that a None
on the inner Option
means ambiguous while a None
on the outer Option
means no object was found. We can do better. To address ambiguity, we've added a new type AmbiguousOption
that operates very much like a regular Option
, but has a third value Ambiguous
that allows the game to directly represent that the result is not None
, or Some
, but is Ambiguous
.
Lets look first at the definition of the AmbiguousOption
type.
#[derive(PartialOrd, Ord, PartialEq, Eq, Debug)]
pub enum AmbiguousOption<T> {
None,
Some(T),
Ambiguous,
}
Explanation
2-6 - The implementation for AmbiguousOption<T>
. As described, it has three potential values - None
, Some
, and Ambiguous
.
1 - Derived traits here allow the game to compare AmbiguousOptions
using the '='
operator.
With AmbiguousOption
we now have the tool we need to modify object_has_label()
, get_object_index()
, get_visible()
and get_posession()
as required to implement #1 and #2 as we described above. We'll look at object_has_label()
first.
impl World {
// --snip--
fn object_has_label(&self, object: &Object, noun: &str) -> bool {
let mut result: bool = false;
for (_, label) in object.labels.iter().enumerate() {
if label.to_lowercase() == noun {
result = true;
break;
}
}
result
}
// --snip--
}
Explanation
4 - The updated implementation for object_has_label()
. Note that we've renamed this function from object_has_name()
to align with the attribute change we made in Object
.
6-11 - Added loop to iterate over the list of labels in the object and check each one to see if it matches the provided noun
. Notice that we've used an actual loop here rather than Vec.contains()
because we need to do a case insensitive comparison.
Next, we can modifiy get_object_index()
to check each Object
to determine if the Object
matches the noun
. Because we need the game to check to see if more than one Object
has the same label this check is always a full scan of the entire Objects
list in the game.
impl World {
// --snip--
fn get_object_index(
&self,
noun: &str,
from: Option<usize>,
max_distance: Distance,
) -> AmbiguousOption<usize> {
let mut result: AmbiguousOption<usize> = AmbiguousOption::None;
for (pos, object) in self.objects.iter().enumerate() {
if self.object_has_label(object, noun)
&& self.get_distance(from, Some(pos)) <= max_distance
{
if result == AmbiguousOption::None {
result = AmbiguousOption::Some(pos);
} else {
result = AmbiguousOption::Ambiguous;
}
}
}
result
}
// --snip--
}
Explanation
4 - The updated implementation for get_object_index()
. Note that the signature is modified to change the return value to an AmbiguousOption
type. The changes we made in the last post to support distance based checks now show their value since we can limit the distance checked for matching objects.
10 - result
is now set to be an AmbiguousOption
type with an initial value of None
.
11-21 - Loop over the Objects
list to check if any of them match the passed noun
.
15-19 - This is a two stage update to the result
value. The first matching object sets result
to Some(pos)
, and the second match (if there is one) sets result
to Ambiguous
.
22 - Return the result
. If the value is Ambiguous
, callers are expected to handle this case appropriately (most likely by displaying a different result to the user.)
The last two changes we need to make for this part of the implementation are updates to get_visible()
and get_possession()
to handle the potentially returned Ambiguous
value from get_object_index()
. When found, we modify the game to produce a message that asks the user to be more specific.
impl World {
// --snip--
fn get_visible(&self, message: &str, noun: &str) -> (String, Option<usize>) {
let obj_over_there = self.get_object_index(noun, Some(LOC_PLAYER), Distance::OverThere);
let obj_not_here = self.get_object_index(noun, Some(LOC_PLAYER), Distance::NotHere);
match (obj_over_there, obj_not_here) {
(AmbiguousOption::None, AmbiguousOption::None) => {
(format!("I don't understand {}.\n", message), None)
}
(AmbiguousOption::None, AmbiguousOption::Some(_)) => {
(format!("You don't see any '{}' here.\n", noun), None)
}
(AmbiguousOption::Ambiguous, _)
| (AmbiguousOption::None, AmbiguousOption::Ambiguous) => (
format!("Please be more specific about which {} you mean.\n", noun),
None,
),
(AmbiguousOption::Some(index), _) => (String::new(), Some(index)),
}
}
pub fn get_possession(
&mut self,
from: Option<usize>,
command: Command,
noun: &str,
) -> (String, Option<usize>) {
let object_held = self.get_object_index(noun, from, Distance::HeldContained);
let object_not_here = self.get_object_index(noun, from, Distance::NotHere);
match (from, object_held, object_not_here) {
(None, _, _) => (
format!("I don't understand what you want to {}.\n", command),
None,
),
(Some(_), AmbiguousOption::None, AmbiguousOption::None) => (
format!("I don't understand what you want to {}.\n", command),
None,
),
(Some(from_idx), AmbiguousOption::None, _) if from_idx == LOC_PLAYER => {
(format!("You are not holding any {}.\n", noun), None)
}
(Some(from_idx), AmbiguousOption::None, _) => (
format!(
"There appears to be no {} you can get from {}.\n",
noun, self.objects[from_idx].labels[0]
),
None,
),
(Some(from_idx), AmbiguousOption::Some(object_held_idx), _)
if object_held_idx == from_idx =>
{
(
format!(
"You should not be doing that to {}.\n",
self.objects[object_held_idx].labels[0]
),
None,
)
}
(Some(_), AmbiguousOption::Ambiguous, _) => (
format!(
"Please be more specific about which {} you want to {}.\n",
noun, command
),
None,
),
(Some(_), AmbiguousOption::Some(object_held_idx), _) => {
("".to_string(), Some(object_held_idx))
}
}
}
// --snip--
}
Explanation
4-22 - Updated implementation of get_visible()
. Most obvious changes are to handle AmbigousOption
in the match statement in lieu of Option
.
15-19 - Added match arm to handle Ambiguous
return from get_object_index()
. Displays an appropriate message to the user asking them to be more specific.
24-78 - Updated implementation of get_possession()
. Most obvious changes are to handle AmbigousOption
in the match statement in lieu of Option
.
65-72 - Added match arm to handle Ambiguous
return from get_object_index()
. Displays an appropriate message to the user asking them to be more specific.
Implementing Directions - Part 3: Handling multi-word nouns
The changes above allow the game to have multiple labels for any Object
, and for Objects
to have non-unique name. The final enhancement we need to make is to allow the player to actually input multi-word nouns (e.g. get glossy photo) so the player can use the more specific names that we've provided for objects when needed. The update occurs in the parse()
function where the input string is broken into the verb
and noun
parts. The enhancement here is merely a step-wise enhancment to the parser. We'll look at processing even more complex inputs in a future post.
pub fn parse(input_str: String) -> Command {
let lc_input_str = input_str.to_lowercase();
let mut split_input_iter = lc_input_str.split_whitespace();
let verb = split_input_iter.next().unwrap_or_default().to_string();
let noun = split_input_iter.fold("".to_string(), |accum, item| {
if accum.is_empty() {
accum + item
} else {
accum + " " + item
}
});
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
6-12 - Modified processing for the noun
to include as part of the noun
all remaining words in the input string. Note that because of the use of split_whitespace()
on line 3, the game will handle extra whitespace in inputs. The modification here uses fold()
to accumulate the remaining values returned by split_input_iter
into a single space separated string.
Progress
The enhancements we made in this post extend the game in a very meaningful way. It is now much easier to navigate around because players do not need to remember passage names, but rather can use directional names.
With these changes, we're in a position to start growing the list of objects much more substantially. But as can be seen here, the list of objects is already getting long. Not too many more and the list will become unwieldy. In the next post we'll look at a different way to declare objects that lets us extract the definitions from the code and into their own file to make the game data driven.
Comments
Post a Comment