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 Visitables 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…