In this second blog post I’m showing how I implemented objects - things the player can interact with - for a text adventure written in Rust. As usual, the full code is available on GitHub.
The unend engine defines a trait which anything which the player can interact with (objects at present time; people, animals etc in the future) must implement:
pub trait Interagible {
fn get_tag(&self) -> String;
fn get_name(&self) -> String;
fn interact(&self, _iact: Interaction) -> InteractionRes;
}
Basically, the interact()
method accepts an interaction as a parameter, and returns a result for that.
Among the “planned for the future” things, there’s method like this:
// Future development
fn interact_with<T: Interagible>(&self, _other: T, _iact: Interaction) -> InteractionRes;
in order to allow interaction between objects, such as use ink with paper. It would also allow the give interaction (as in give book to Alice) to exist. This is, however, absolutely too advanced for current development status. 😆
Interaction
and InteractionRes
are enums defined as follows:
/// A complete set of interactions (these come from Thimbleweed Park, with
/// just a few variations to keep them 1-word long)
pub enum Interaction {
Open,
Close,
Give, // Placeholder, needs `interact_with()` to exist
Take,
Look,
Talk,
Push,
Pull,
Use,
}
/// Possible results for an interaction. Plan for this is to be a complete
/// set of possible results. For now there are one two basic (but useful) ones.
pub enum InteractionRes {
Info(String),
GotoSection(String),
}
The first object I wanted to implement was a very simple one, that would just show a text message for each interaction: a description if it’s looked at, a nice “no, thank you” message if the player attempted to take it, etc. So, leaving aside trait instantiation and other boilerplate, all the action happens in this code:
impl Interagible for InfoObject {
// ... cut ...
fn interact(&self, iact: Interaction) -> InteractionRes {
match self.av_interactions.get(&iact) {
Some(s) => InteractionRes::Info(s.to_string()),
None => InteractionRes::Info("That won't work".to_string())
}
}
}
The implementation of Interagible
’s interact()
method is just a few lines: the code looks into
an HashMap
(contained in the InfoObject
struct) to see if there is a result for the interaction
requested (we’ll see in a while how possible results are added when creating a new InfoObject
):
if a match is found, we return an Info
value of the InteractionRes
enum, which contains a
simple string to show to the player; with no match, we return the same type, but with a default string.
Now to the main loop code:
let interaction_regex = Regex::new(r"^(\w+)\s+(\w+)$").unwrap();
loop {
// ... cut ...
match command.as_str() {
// ... cut ...
irx if interaction_regex.is_match(irx) => {
// ... cut: get target `obj` and `interaction` from captures and look them up ...
match target {
UnendObject::Info(obj) => match obj.interact(interaction) {
InteractionRes::Info(s) => self.write_line(&s),
_ => panic!("InfoObject shouldn't interact this way."),
},
// ... cut ...
};
}
};
};
The parser is just a regular expression at the moment. Once it matches the type of the target
(the enum in which it’s contained was previously looked up via the object’s tag), the main loop
invokes its interact()
method, passing the (previously looked up) Interaction
.
We use match in order to unwrap the InteractionRes
enum: if the value is
Info
we show the contained string to the player; other result values are not supported for this
specific kind of object, so a panic!()
is probably the best option (=forces somebody to debug
the thing).
Now that we have all of this setup, it is possible to modify the code - shown in previous article - which was used to create the kitchen, so that is becomes:
let kitchen = UnendSection::Basic(BasicSection::new(
s!("kitchen"),
s!("The grand kitchen"),
s!("You are at the center of the kitchen and dining room. The only exit is south. There's a *book* on the table."),
hashmap!{
ExitDir::South => Exit::Visitable(s!("hallway")),
ExitDir::East => Exit::Closed(s!("You can't exit through the window.")),
},
hashmap!{s!("book") => UnendObject::Info(InfoObject::new(
s!("book"),
s!("Pink Book"),
hashmap!{
Interaction::Look => s!("It is a strange pink book with a black sheep on the cover."),
Interaction::Take => s!("I don't need this book."),
Interaction::Use => s!("Hmmm, I prefer to watch movies rather than read."),
},
))},
));
The code should be self-explanatory enough. The only thing worth noting is that InfoObject
is wrapped
into an UnendObject::Info
enum value: as we did for sections, this allows to have different object
(Interagible
) types inside the same containing HashMap
.
So, let’s see if this works somehow:
And now… a slightly different implementation: a PortalObject
, which we define as an object which
transports the player to another section of the game when used.
Skipping the struct constructor and other service code (which you can find the in the repository),
the interesting part comes (as before) with the interact()
method:
fn interact(&self, iact: Interaction) -> InteractionRes {
match iact {
Interaction::Look => InteractionRes::Info(self.dsc.clone()),
Interaction::Use => InteractionRes::GotoSection(self.destination.clone()),
_ => InteractionRes::Info("That won't work".to_string()),
}
}
Only two interactions are supported by PortalObject
: the player can only look at it or use it.
Everything else shows a default string. Compared to InfoObject
, the difference here lies in how
the Interaction::Use
is handled, that is by returning an InteractionRes::GotoSection
containing
a string with the tag of the destination section. The main loop code will therefore know how
to use that tag:
loop {
// ... cut ...
match command.as_str() {
// ... cut ...
irx if interaction_regex.is_match(irx) => {
// ... cut: get target `obj` and `interaction` from captures and look them up ...
match target {
// ... cut ...
UnendObject::Portal(obj) => match obj.interact(interaction) {
InteractionRes::Info(s) => self.write_line(&s),
InteractionRes::GotoSection(s) => {
self.current_section_tag = s;
continue;
}
},
};
}
};
};
Dead easy: either show the description or change the section the player is in and skip to the next loop cycle. Now we only need to actually create the portal object and put it inside a section/room.
let hallway = UnendSection::Basic(BasicSection::new(
s!("hallway"),
// ... cut ...
hashmap!{s!("fireplace") => UnendObject::Portal(PortalObject::new(
s!("fireplace"),
s!("A strange fireplace"),
s!("This fireplace glows like it's enchanted."),
s!("secretroom"),
))},
));
A fireplace which happens to be a portal… how interesting! The secretroom, defined elsewhere, is a section for which there is no access from any of the other sections; the secretroom itself, however, has an exit which brings the player back to the hallway.
That’s all! See you next time with something else. I’ll likely try to implement
inventory, and therefore objects which can be taken. This poses some challenges: as of now,
everything in the game has an immutable state; using mutable states, besides the programming aspects
(which may be non-trivial for a Rust newbie like me), will also likely lead to some changes in how
Visitable
s are structures: if an object is taken, it must disappear from the section, and the
section description (which is now a monolithic text block) should change. We’ll see…