How to make a Text Adventure game in Rust - VI - Passages
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 |
6 - Passages
- A starting point (location),
- An ending point (location),
- A narrative description, for example "a forest path," and
- A name by which the player may refer to the passage.
Object
struct looks very much like what the game would need to represent a passage. Indeed to allow the Object
struct to store passages we would need to add only one field - the destination of the passage.list_objects_at_location()
will display passages like other items, other functions such as do_go()
will treat passages as a different kind of item that moves a player from one location to another.Updating the Object
struct
Object
struct and then propagate the necessary changes through the command and helper functions in the game code.pub struct Object {
pub name: String,
pub description: String,
pub location: Option<usize>,
pub destination: Option<usize>,
}
Explanation
5 - destination: the index into the objects vec()
of the object's current location.
location
to the destination
. If we want passages to go in both directions, we'll need to add two passages, one for each direction.Adding Passages
Object
struct updated, we can add passages to the game. From the diagram above, we can see that we'll need to add 4 passages, forward and aft between the galley and the bridge, and port and starboard between the galley and the cryochamber. The code below shows those additions.const LOC_BRIDGE: usize = 0;
const LOC_GALLEY: usize = 1;
const LOC_CRYOCHAMBER: usize = 2;
const LOC_PLAYER: usize = 3;
const LOC_PHOTO: usize = 4;
const LOC_CRYOSUIT: usize = 5;
const LOC_COPILOT: usize = 6;
const LOC_PEN: usize = 7;
const AFT_TO_GALLEY: usize = 8;
const FWD_TO_BRIDGE: usize = 9;
const PORT_TO_CRYOCHAMBER: usize = 10;
const STBD_TO_GALLEY: usize = 11;
impl World {
pub fn new() -> Self {
World {
objects: vec![
// --snip--
Object {
name: "Pen".to_string(),
description: "a pen".to_string(),
location: Some(LOC_COPILOT),
destination: None,
},
Object {
name: "Aft".to_string(),
description: "a passage aft to the galley".to_string(),
location: Some(LOC_BRIDGE),
destination: Some(LOC_GALLEY),
},
Object {
name: "Forward".to_string(),
description: "a passage forward to the bridge".to_string(),
location: Some(LOC_GALLEY),
destination: Some(LOC_BRIDGE),
},
Object {
name: "Port".to_string(),
description: "a passage port to the cryochamber".to_string(),
location: Some(LOC_GALLEY),
destination: Some(LOC_CRYOCHAMBER),
},
Object {
name: "Starboard".to_string(),
description: "a passage starboard to the galley".to_string(),
location: Some(LOC_CRYOCHAMBER),
destination: Some(LOC_GALLEY),
},
],
}
}
// --snip--
}
Explanation
9-12 - Added consts that contain indexes into the objects vec()
. These make referring to the passages easier when constructing Object
s for the objects vec()
.
21-26 - An example of one of the existing Object
s that shows the newly added destination
field. For all non-passage objects, destination is set to None
.
27-50 - New objects that represent the passages between locations in the game. Note that the location
and the destination
fields have values that indicate the starting and ending points of the passage.
Finding Passages with get_passage()
get_passage_index()
that iterates over the objects vec()
and checks each Object
to see if a matching passage exists between the locations.impl World {
// --snip--
fn get_passage_index(&self, from: Option<usize>, to: Option<usize>) -> Option<usize> {
let mut result: Option<usize> = None;
match (from, to) {
(Some(from), Some(to)) => {
for (pos, object) in self.objects.iter().enumerate() {
let obj_loc = object.location;
let obj_dest = object.destination;
match (obj_loc, obj_dest) {
(Some(location), Some(destination))
if location == from && destination == to =>
{
result = Some(pos);
break;
}
_ => continue,
}
result
}
}
_ => result,
}
}
// --snip--
}
Explanation
8-26 - Outer match
, checks to ensure that both from
and to
are not None
.
10-23 - Iterate over the objects vec()
and check to see if any Object
is a passage matching the from
and to
.
17-18 - If a matching passage is found, return the passage index value as Some
.
22 - Return the found passage as Some
(from 17), or None
(from 6).
Modify do_go()
get_passage_index()
in do_go()
to move the player from location to location. Note that we've expanded do_go()
quite a bit to only allow the cases where the command is go <location>
or go <passage>
where either the location or passage is visible.impl World {
// --snip--
pub fn do_go(&mut self, noun: &str) -> 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);
let obj_dst = obj_opt.and_then(|a| self.objects[a].destination);
let player_loc = self.objects[LOC_PLAYER].location;
let passage = self.get_passage_index(player_loc, obj_opt);
match (obj_opt, obj_loc, obj_dst, player_loc, passage) {
// Is noun an object at all?
(None, _, _, _, _) => output_vis,
// Is noun a location and is there a passage to it?
(Some(obj_idx), None, _, _, Some(_)) => {
self.objects[LOC_PLAYER].location = Some(obj_idx);
"OK.\n\n".to_string() + &self.do_look("around")
}
// Noun isn't a location. Is noun at a different location than the player?
// (i.e. Object has Some location)
(Some(_), Some(obj_loc_idx), _, Some(player_loc), None)
if obj_loc_idx != player_loc =>
{
format!("You don't see any {} here.\n", noun)
}
// Noun isn't a location. Is it a passage?
// (i.e. Object has Some location and Some destination)
(Some(_), Some(_), Some(obj_dst_idx), Some(_), None) => {
self.objects[LOC_PLAYER].location = Some(obj_dst_idx);
"OK.\n\n".to_string() + &self.do_look("around")
}
// Noun might be a location or an object at the location, but there isn't a destination so it isn't a path,
// then Noun must be the player's current or something at the location
(Some(_), _, None, Some(_), None) => {
"You can't get much closer than this.\n".to_string()
}
// Else, just return the string from get_visible
_ => output_vis,
}
}
}
Explanation
11 - The call to get_passage_index()
. Will return None
if no passage exists, or Some
if one does.
13 - Match on a tuple that includes all of the elements we need to select on - the object, the location, the destination, the player location, and the passage.
15 - Is noun
an Object
at all? (If not, then obj_opt
will be None
). Just return the string passed back from get_visible()
.
17-20 - Is the noun
a location (i.e. obj_opt
is Some
and obj_loc
is None
), and is there a passage to it (i.e. passage
is Some
)? If yes, then we can move the player there.
23-27 - If the noun
is an item or passage (i.e. obj_opt
and obj_loc
are Some
) but it is not in the same location as the player (i.e. obj_loc != player_loc
), then the object is not here. Print a message letting the player know that and return.
30-33 - Is the noun
a passage (i.e. obj_opt
, obj_loc
, and obj_dst
are Some
)? If yes, we know it is in the same location as the player or the previous arm would match. If yes, then move the player to the passage destination.
36-38 - By this arm, we know the noun
is either a location or an object. It can't be a passage because obj_dst
is None, but also because if it were a passage then the previous arm would match. The only option left is that the noun
is the current location or an object at the current location. Just print a message and return.
40 - Rust forces matches to be comprehensive. There are combinations of match values that don't make sense with our values (i.e. an Object
with no location but having a destination). These cases cannot exist in the game. Here we just print the message from get_visible()
and return.
Modify get_visible()
get_visible()
. Previously get_visible()
allowed all locations to be visible from all other locations. The updated version only allows locations to be visible if they are connected to the current location by a passage.impl World {
// --snip--
fn get_visible(&self, message: &str, noun: &str) -> (String, Option<usize>) {
let mut output = String::new();
let obj_index = self.get_object_index(noun);
let obj_loc = obj_index.and_then(|a| self.objects[a].location);
let obj_container_loc = obj_index
.and_then(|a| self.objects[a].location)
.and_then(|b| self.objects[b].location);
let player_loc = self.objects[LOC_PLAYER].location;
let passage = self.get_passage_index(player_loc, obj_index);
match (obj_index, obj_loc, obj_container_loc, player_loc, passage) {
// Noun isn't an object
(None, _, _, _, _) => {
output = format!("I don't understand {}.\n", message);
(output, None)
}
//
// For all the below cases, we've found an object, but should the player know that?
//
// Is this object the player?
(Some(obj_index), _, _, _, _) if obj_index == LOC_PLAYER => (output, Some(obj_index)),
//
// Is this object the location the player is in?
(Some(obj_index), _, _, Some(player_loc), _) if obj_index == player_loc => {
(output, Some(obj_index))
}
//
// Is this object being held by the player (i.e. 'in' the player)?
(Some(obj_index), Some(obj_loc), _, _, _) if obj_loc == LOC_PLAYER => {
(output, Some(obj_index))
}
//
// Is this object at the same location as the player?
(Some(obj_index), Some(obj_loc), _, Some(player_loc), _) if obj_loc == player_loc => {
(output, Some(obj_index))
}
//
// If this object is a location (i.e. it has Some obj_loc,
// we only care if there is a passage to it)
(Some(obj_index), None, _, _, Some(_)) => (output, Some(obj_index)),
//
// Is this object contained by any object held by the player
(Some(obj_index), Some(_), Some(obj_container_loc), _, _)
if obj_container_loc == LOC_PLAYER =>
{
(output, Some(obj_index))
}
//
// Is this object contained by any object at the player's location?
(Some(obj_index), Some(_), Some(obj_container_loc), Some(player_loc), _)
if obj_container_loc == player_loc =>
{
(output, Some(obj_index))
}
//
// If none of the above, then we don't know what the noun is.
_ => {
output = format!("You don't see any '{}' here.\n", noun);
(output, None)
}
}
}
// --snip--
}
Explanation
14 - The added call to get_passage_index()
. Will return None
if no passage exists, or Some
if one does.
16 - match
on a tuple that includes all of the elements we need to select on - the object, the location, the container (of the object), the player location, and the passage.
18-65 - For all match
arms, we've added a fifth element that is the passage. Most arms do not care about this element and remain unchanged from the prior implementation.
18-21 - Is noun
an Object
at all? (If not, then obj_index
will be None
). Return an error indicating that the object is unknown.
45 - For locations (i.e. obj_index
is Some
, but obj_loc
is None
), then only return the object as visible if there is a passage to the location (i.e. passage
is Some
).
Progress
Adding passages changes the character of the game experience for the player. Just as in real life, the world is no longer a set of disconnected places, but rather a map of connected spaces. As a player, interacting with a world connected by passages provides a much more real experience because it more closely mirrors the world as you know it. The map shown here is still very simple and has only four passages connecting the three locations, but the technique here can be applied to much larger maps and spaces.
Next post we'll explore the concept of distance in a text adventure game. We'll consider distance as not just the distance between locations, but also the difference in the proximity of objects. This concept should help us to clean up the game code and simplify some of the complicated match statements that we've been writing.
Comments
Post a Comment