How to make a Text Adventure game in Rust - II - The Main Loop
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.
2 - The Main Loop
- Check for player input,
- Update the game state based on the input, and
- Refresh the screen based on the game state.
Collect input
Update the game state
Refresh the screen
Writing the Main Loop
main.rs
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::new();
let mut output: String;
//
// Main Loop
//
while command.verb != "quit" {
command = rlib::get_input();
output = rlib::update_state(&command);
rlib::update_screen(output);
}
//
// Shutdown and Exit
//
println!("Bye!");
}
Explanation
1 - Import the Rentry library module
rlib
.
3-11 - This is the first steps code.
13 -
Create a Command
struct to hold user entered commands. We'll
describe this in more detail below.
14 - Create a
String
to hold output for the screen
19 - 23 - The main
loop. Continue to loop until the user enters 'quit'
20 - Main loop
step 1 - Check for input. This call blocks while waiting for input.
Returns a Command
struct.
21 - Main loop step 2 - Update
the game state with the Command
value. Returns a
String
with output lines.
22 - Main loop step 3 - Send
the output to the screen for display.
28 - Print 'Bye!' and shutdown
the game.
The rlib
Module
In the main function, above, we called out to a different module
'rlib.'
rlib
(the Reentry library module)
provides several functions and the Command
struct. Lets look
at the rlib
module and those parts now to see what is
happening with them. We'll examine them in sections. First up is the
Command
struct.
Command
holds the action that is parsed from the user entered
string. The code shows the struct and the two implemented methods on
the struct.
pub struct Command {
pub verb: String,
pub noun: String,
}
impl Command {
pub fn new() -> Command {
Command {
verb: String::new(),
noun: String::new(),
}
}
fn parse(&mut self, input_str: &str) {
let mut split_input_iter = input_str.trim().split_whitespace();
self.verb = split_input_iter.next().unwrap_or_default().to_string();
self.noun = split_input_iter.next().unwrap_or_default().to_string();
}
}
Command
struct itself. It contains
verb
, and noun
, two variables of
String
type.
new()
function for Command
. The
declaration is made public so we can create a Command
in the
main function. Implmentation is simple and just creates two
Strings
to populate the struct fields verb
and
nown
.
parse()
function. It takes as input
a reference to self
and a reference to
input_str
as a &str
type.
input_str
on white space. Creates an iterator on the split
values.
verb
and noun
. Note that we have to call
unwrap_or_default()
to accommodate the
Option
returned by the iterator. These lines need to function
if no value is returned from the iterator. Note also that we only take the
first two words in the input string.
Command
lays the foundation for much more advanced parsing that
may come later in our game. Having input and parsing as part of the
implementation means that we can expand parsing as we need to and we'll
still have an easy way to move the parsed values around as a single unit.
For example, we might want to switch verb
from a simple string
to an enum that holds the parsed commands. This would let us deal more
easily with command shortcuts like 'n' when the player means to 'go north.'
In another example, we might change Command
so that it contains
an array of actions that the game would take. This would allow the game to
'inject' actions that the player might trigger, but that they did not
initiate directly.
Command
defined we can look at get_input()
to
see how we get new commands from the player.
use std::io::{self, Write};
pub fn get_input() -> Command {
// Prompt
println!("");
print!("> ");
io::stdout().flush().unwrap();
let mut input_str = String::new();
io::stdin()
.read_line(&mut input_str)
.expect("Failed to read move");
println!("");
// Parse
let mut command = Command::new();
command.parse(input_str.as_str());
// Return
command
}
std::io
and std::io::Write
to
support IO operations and the call to flush()
.
get_input()
function. Returns a
Command
struct with a parsed command.4 - 15 - Prompt for input and wait for a command.
unwrap()
here. We have to add this here or Rust won't
compile. flush()
returns a Result
and if we do
not unwrap()
then Rust sees it as an unhandled
Result
case and throws an error. This is also a bit of a red
flag since we're using unwrap()
. unwrap()
in
Rust is often a sign that we're ignoring possible error cases, which can
come back to bite us later. We're using unwrap()
here to move
quickly, but later we'll probably want to come back and add proper
handling for this (and other) unwrap()
calls that appear.
String
variable to hold the user entered string.
expect()
is another case where we're glossing over the
possibly unhandled error conditions.
Command
object to hold the parsed command.
parse()
function defined on the Command
struct
that we saw earlier.
update_state()
and
update_screen()
:
pub fn update_state(command: &Command) -> String {
let output: String;
match command.verb.as_str() {
"quit" => output = format!("Quitting.\nThank you for playing!"),
"look" => output = format!("It is very dark, you can see nothing but the flashing light."),
"go" => output = format!("It is too dark to move."),
_ => output = format!("I don't know how to '{}'."),
}
// Return
output
}
pub fn update_screen(output: String) {
println!("{}", output);
}
update_state()
function.
update_state()
takes one argument of type
Command
, and returns a String
.
String
when initialized
verb
string to determine action. For each
command, create a String
and assign it to output.
String
update_screen()
function, which takes as
input a String
.
Progress
cargo run
shows the kind of interactions the game
supports already.
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
It is very dark, you can see nothing but the flashing light.
> go north
It is too dark to move.
> examine light
I don't know how to do that.
> quit
Quitting.
Thank you for playing!
Bye!
You may have done this already, but I think the more idiomatic way to write the initialization of output in update_state would be to set output equal to the result of the match expression.
ReplyDeleteThis is a great point and one that gets fixed in the third post (https://www.riskpeep.com/2022/08/make-text-adventure-game-rust-3.html). I'm still very much learning Rust though, so I'm sure there are many similar mistakes. I appreciate the feedback.
DeleteHi, Rob! Thank you so much! I found a typo: "All of this information is typical stored in a single or series of shared data structures". Typical = typically
ReplyDelete