modboard;modgame;modlevel;modplayer;modraw_mode;usecrate::{game::Game,raw_mode::RawMode};pubconstBOARD_WIDTH: usize=39;pubconstBOARD_HEIGHT: usize=20;pubconstTILE_SIZE: usize=2;pubconstANSI_YELLOW: &str="\x1B[33m";pubconstANSI_GREEN: &str="\x1B[32m";pubconstANSI_CYAN: &str="\x1B[36m";pubconstANSI_RESET: &str="\x1B[39m";#[derive(Copy, Clone, Debug, PartialEq)]pubenumTile{Empty,// There will be empty spaces on our board " "
Player,// We will need the player "◀▶"
Block,// Some tiles will be blocks "░░"
StaticBlock,// Others will be blocks that can't be moved "▓▓"
}pubenumDirection{Up,Right,Down,Left,}#[derive(Debug, Copy, Clone)]pubstructCoord{column: usize,row: usize,}fnmain(){let_raw_mode=RawMode::enter();letmutgame=Game::new();game.play();}
The game module contains our Game struct with it’s own render method and the play method to start the game:
In the board module we setup our Board structure which is responsible to generate the terrain, render the inside of
the board and implements our own Index trait to make it easier for us to work with the board:
userand::seq::SliceRandom;usecrate::{ANSI_CYAN,ANSI_GREEN,ANSI_RESET,ANSI_YELLOW,BOARD_HEIGHT,BOARD_WIDTH,Coord,TILE_SIZE,Tile,level::{Level,LevelConfig},};usestd::ops::{Index,IndexMut};#[derive(Debug)]pubstructBoard{pubbuffer: [[Tile;BOARD_WIDTH];BOARD_HEIGHT],}implIndex<&Coord>forBoard{typeOutput=Tile;fnindex(&self,coord: &Coord) -> &Self::Output{&self.buffer[coord.row][coord.column]}}implIndexMut<&Coord>forBoard{fnindex_mut(&mutself,coord: &Coord) -> &mutSelf::Output{&mutself.buffer[coord.row][coord.column]}}implBoard{pubfnnew() -> Self{letmutbuffer=[[Tile::Empty;BOARD_WIDTH];BOARD_HEIGHT];letmutall_coords=(0..BOARD_HEIGHT).flat_map(|row|(0..BOARD_WIDTH).map(move|column|Coord{column,row})).filter(|coord|!(coord.column==0&&coord.row==0)).collect::<Vec<Coord>>();letmutrng=rand::rng();all_coords.shuffle(&mutrng);buffer[0][0]=Tile::Player;letLevelConfig{block_count,static_block_count,}=Level::One.get_level_config();for_in0..block_count{letcoord=all_coords.pop().expect("We tried to place more blocks than there were available spaces on the board",);buffer[coord.row][coord.column]=Tile::Block;}for_in0..static_block_count{letcoord=all_coords.pop().expect("We tried to place more static blocks than there were available spaces on the board",);buffer[coord.row][coord.column]=Tile::StaticBlock;}Self{buffer}}pubfnrender(&self) -> String{letmutoutput=format!("{ANSI_YELLOW}▛{}▜{ANSI_RESET}\n","▀".repeat(BOARD_WIDTH*TILE_SIZE));forrowsinself.buffer{output.push_str(&format!("{ANSI_YELLOW}▌{ANSI_RESET}"));fortileinrows{matchtile{Tile::Empty=>output.push_str(" "),Tile::Player=>{output.push_str(&format!("{ANSI_CYAN}◀▶{ANSI_RESET}"))},Tile::Block=>{output.push_str(&format!("{ANSI_GREEN}░░{ANSI_RESET}"))},Tile::StaticBlock=>{output.push_str(&format!("{ANSI_YELLOW}▓▓{ANSI_RESET}"))},}}output.push_str(&format!("{ANSI_YELLOW}▐{ANSI_RESET}\n"));}output.push_str(&format!("{ANSI_YELLOW}▙{}▟{ANSI_RESET}","▄".repeat(BOARD_WIDTH*TILE_SIZE)));output}}
The level module contains our level enum which implements a way for us to get a config for each level:
The goal is to add a different number of enemies per level.
While level 1 adds 3, later levels will increase the number of beasts to make the challenge harder as you play through
the game.
We will have to create a single beast module that we can instantiate multiple times per level which will take care of
its own position on the board and how it moves.
In a way, this module will likely be pretty similar to our player module because it will do similar things.
For this tutorial we will only build a single type of beast but the original game had three different types which all
presented a different type of challenge.
Because this is a Rust tutorial and I think it would be fun for you to build your own beast type on your own outside
this tutorial, we should build our own beast trait to make that
easier.
We’ve used a couple traits from the standard library in the past parts like Copy and Debug.
It’s time now to build our very own.
To start off, let’s create a folder which will contain our beasts, aptly named beasts and a module file called
beasts.rs.
Inside the beast folder we add a file called beast_trait.rs.
The beasts.rs file we use to re-export everything inside the beasts/ folder to make importing a little easier.
For now, let’s just re-export anything that is inside our beasts/beast_trait.rs file:
1
2
pubmodbeast_trait;pubusebeast_trait::*;
Now we only need to include the beasts module in our main.rs file and not each file inside the beasts folder.
modbeasts;modboard;modgame;modlevel;modplayer;modraw_mode;usecrate::{game::Game,raw_mode::RawMode};pubconstBOARD_WIDTH: usize=39;pubconstBOARD_HEIGHT: usize=20;pubconstTILE_SIZE: usize=2;pubconstANSI_YELLOW: &str="\x1B[33m";pubconstANSI_GREEN: &str="\x1B[32m";pubconstANSI_CYAN: &str="\x1B[36m";pubconstANSI_RESET: &str="\x1B[39m";#[derive(Copy, Clone, Debug, PartialEq)]pubenumTile{Empty,// There will be empty spaces on our board " "
Player,// We will need the player "◀▶"
Block,// Some tiles will be blocks "░░"
StaticBlock,// Others will be blocks that can't be moved "▓▓"
}pubenumDirection{Up,Right,Down,Left,}#[derive(Debug, Copy, Clone)]pubstructCoord{column: usize,row: usize,}fnmain(){let_raw_mode=RawMode::enter();letmutgame=Game::new();game.play();}
The idea is to contain all types for beasts in the beasts folder and re-export them from within the beasts.rs file.
You might end up with your own enemies later and that folder is where you’d drop them in.
OK, let’s now look at the beasts/beast_trait.rs file.
What do we need for a beast to slot into our game?
We need to be able to create a new beast and we need to move/advance the beast:
When creating a new instance of a beast with the new method, we will have to tell it where we placed the beast on the
board.
And the advance method will need to know about the board and where the player is.
It also may or may not find a coordinate to move into so we return an Option.
We will return the coordinate and only take Board by immutable reference because the beast could kill a player which
is logic we don’t really want to keep in the beast module.
That kind of logic should be contained in the Game module as it will effect changes to the players lives and possible
end the game.
Now that we have our trait definition done, let’s add our first beast.
In the original game, the simplest beasts that appeared in the first few levels were called Common Beast so let’s roll
with that:
We imported our trait, Coord and Board and then setup a new struct called CommonBeast which will hold a Coord in
the position variable.
Then we implement our trait on that new struct.
To keep things simple for now, we just return a None from our advance method.
Now all we need to do make this new module available to the rest of the codebase is to re-export it from our beasts.rs
file:
Now any other module in our codebase can import our CommonBeast (because we also made it public).
Next, let’s us integrate the beast into our code so we have something to look at when we build out the pathfinding
later.
First we need to extend our Tile enum to include a common beast:
We also removed the comments for each Tile option just to keep things clean.
With new options in options, our compiler will tell us that the match call in our render method in our board
module is non-exhaustive anymore:
cargo check
Compiling beast v0.1.0 (/Users/code/beast)
[...some warnings removed]error[E0004]: non-exhaustive patterns: `Tile::CommonBeast` not covered--> src/board.rs:74:11
|74| match tile {
|^^^^pattern `Tile::CommonBeast` not covered|note: `Tile` defined here
--> src/main.rs:20:10
|20| pub enum Tile {
|^^^^...25| CommonBeast,
|-----------not covered= note: the matched value is of type `Tile`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|84~ },85~ Tile::CommonBeast => todo!(),
|error[E0004]: non-exhaustive patterns: `Tile::CommonBeast` not covered--> src/player.rs:58:10
|58| match board[&first_position] {
|^^^^^^^^^^^^^^^^^^^^^^pattern `Tile::CommonBeast` not covered|note: `Tile` defined here
--> src/main.rs:20:10
|20| pub enum Tile {
|^^^^...25| CommonBeast,
|-----------not covered= note: the matched value is of type `Tile`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|90~ Tile::Player | Tile::StaticBlock => {},91~ Tile::CommonBeast => todo!(),
|error[E0004]: non-exhaustive patterns: `Tile::CommonBeast` not covered--> src/player.rs:75:14
|75| match current_tile {
|^^^^^^^^^^^^pattern `Tile::CommonBeast` not covered|note: `Tile` defined here
--> src/main.rs:20:10
|20| pub enum Tile {
|^^^^...25| CommonBeast,
|-----------not covered= note: the matched value is of type `Tile`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|83~ Tile::StaticBlock | Tile::Player => break,84~ Tile::CommonBeast => todo!(),
|For more information about this error, try `rustc --explain E0004`.warning: `beast` (bin "beast") generated 3 warnings
error: could not compile `beast` (bin "beast") due to 3 previous errors; 3 warnings emitted
In fact, it finds three areas in our code where we match against Tile.
How good is it to have the compiler help us like this?
Refactoring code becomes very straight forward.
userand::seq::SliceRandom;usecrate::{ANSI_CYAN,ANSI_GREEN,ANSI_RESET,ANSI_YELLOW,BOARD_HEIGHT,BOARD_WIDTH,Coord,TILE_SIZE,Tile,level::{Level,LevelConfig},};usestd::ops::{Index,IndexMut};#[derive(Debug)]pubstructBoard{pubbuffer: [[Tile;BOARD_WIDTH];BOARD_HEIGHT],}implIndex<&Coord>forBoard{typeOutput=Tile;fnindex(&self,coord: &Coord) -> &Self::Output{&self.buffer[coord.row][coord.column]}}implIndexMut<&Coord>forBoard{fnindex_mut(&mutself,coord: &Coord) -> &mutSelf::Output{&mutself.buffer[coord.row][coord.column]}}implBoard{pubfnnew() -> Self{letmutbuffer=[[Tile::Empty;BOARD_WIDTH];BOARD_HEIGHT];letmutall_coords=(0..BOARD_HEIGHT).flat_map(|row|(0..BOARD_WIDTH).map(move|column|Coord{column,row})).filter(|coord|!(coord.column==0&&coord.row==0)).collect::<Vec<Coord>>();letmutrng=rand::rng();all_coords.shuffle(&mutrng);buffer[0][0]=Tile::Player;letLevelConfig{block_count,static_block_count,}=Level::One.get_level_config();for_in0..block_count{letcoord=all_coords.pop().expect("We tried to place more blocks than there were available spaces on the board",);buffer[coord.row][coord.column]=Tile::Block;}for_in0..static_block_count{letcoord=all_coords.pop().expect("We tried to place more static blocks than there were available spaces on the board",);buffer[coord.row][coord.column]=Tile::StaticBlock;}Self{buffer}}pubfnrender(&self) -> String{letmutoutput=format!("{ANSI_YELLOW}▛{}▜{ANSI_RESET}\n","▀".repeat(BOARD_WIDTH*TILE_SIZE));forrowsinself.buffer{output.push_str(&format!("{ANSI_YELLOW}▌{ANSI_RESET}"));fortileinrows{matchtile{Tile::Empty=>output.push_str(" "),Tile::Player=>{output.push_str(&format!("{ANSI_CYAN}◀▶{ANSI_RESET}"))},Tile::Block=>{output.push_str(&format!("{ANSI_GREEN}░░{ANSI_RESET}"))},Tile::StaticBlock=>{output.push_str(&format!("{ANSI_YELLOW}▓▓{ANSI_RESET}"))},Tile::CommonBeast=>{output.push_str(&format!("{ANSI_YELLOW}├┤{ANSI_RESET}"))},}}output.push_str(&format!("{ANSI_YELLOW}▐{ANSI_RESET}\n"));}output.push_str(&format!("{ANSI_YELLOW}▙{}▟{ANSI_RESET}","▄".repeat(BOARD_WIDTH*TILE_SIZE)));output}}
But the beast should be red so let’s add a ANSI_RED const to our main file and import it here:
userand::seq::SliceRandom;usecrate::{ANSI_CYAN,ANSI_GREEN,ANSI_RED,ANSI_RESET,ANSI_YELLOW,BOARD_HEIGHT,BOARD_WIDTH,Coord,TILE_SIZE,Tile,level::{Level,LevelConfig},};usestd::ops::{Index,IndexMut};#[derive(Debug)]pubstructBoard{pubbuffer: [[Tile;BOARD_WIDTH];BOARD_HEIGHT],}implIndex<&Coord>forBoard{typeOutput=Tile;fnindex(&self,coord: &Coord) -> &Self::Output{&self.buffer[coord.row][coord.column]}}implIndexMut<&Coord>forBoard{fnindex_mut(&mutself,coord: &Coord) -> &mutSelf::Output{&mutself.buffer[coord.row][coord.column]}}implBoard{pubfnnew() -> Self{letmutbuffer=[[Tile::Empty;BOARD_WIDTH];BOARD_HEIGHT];letmutall_coords=(0..BOARD_HEIGHT).flat_map(|row|(0..BOARD_WIDTH).map(move|column|Coord{column,row})).filter(|coord|!(coord.column==0&&coord.row==0)).collect::<Vec<Coord>>();letmutrng=rand::rng();all_coords.shuffle(&mutrng);buffer[0][0]=Tile::Player;letLevelConfig{block_count,static_block_count,}=Level::One.get_level_config();for_in0..block_count{letcoord=all_coords.pop().expect("We tried to place more blocks than there were available spaces on the board",);buffer[coord.row][coord.column]=Tile::Block;}for_in0..static_block_count{letcoord=all_coords.pop().expect("We tried to place more static blocks than there were available spaces on the board",);buffer[coord.row][coord.column]=Tile::StaticBlock;}Self{buffer}}pubfnrender(&self) -> String{letmutoutput=format!("{ANSI_YELLOW}▛{}▜{ANSI_RESET}\n","▀".repeat(BOARD_WIDTH*TILE_SIZE));forrowsinself.buffer{output.push_str(&format!("{ANSI_YELLOW}▌{ANSI_RESET}"));fortileinrows{matchtile{Tile::Empty=>output.push_str(" "),Tile::Player=>{output.push_str(&format!("{ANSI_CYAN}◀▶{ANSI_RESET}"))},Tile::Block=>{output.push_str(&format!("{ANSI_GREEN}░░{ANSI_RESET}"))},Tile::StaticBlock=>{output.push_str(&format!("{ANSI_YELLOW}▓▓{ANSI_RESET}"))},Tile::CommonBeast=>{output.push_str(&format!("{ANSI_RED}├┤{ANSI_RESET}"))},}}output.push_str(&format!("{ANSI_YELLOW}▐{ANSI_RESET}\n"));}output.push_str(&format!("{ANSI_YELLOW}▙{}▟{ANSI_RESET}","▄".repeat(BOARD_WIDTH*TILE_SIZE)));output}}
usecrate::{BOARD_HEIGHT,BOARD_WIDTH,Coord,Direction,Tile,board::Board};#[derive(Debug)]pubstructPlayer{position: Coord,}implPlayer{pubfnnew() -> Self{Self{position: Coord{column: 0,row: 0},}}fnget_next_position(position: Coord,direction: &Direction,) -> Option<Coord>{letmutnext_position=position;matchdirection{Direction::Up=>{ifnext_position.row>0{next_position.row-=1}else{returnNone;}},Direction::Right=>{ifnext_position.column<BOARD_WIDTH-1{next_position.column+=1}else{returnNone;}},Direction::Down=>{ifnext_position.row<BOARD_HEIGHT-1{next_position.row+=1}else{returnNone;}},Direction::Left=>{ifnext_position.column>0{next_position.column-=1}else{returnNone;}},}Some(next_position)}pubfnadvance(&mutself,board: &mutBoard,direction: &Direction){ifletSome(first_position)=Self::get_next_position(self.position,direction){matchboard[&first_position]{Tile::Empty=>{board[&self.position]=Tile::Empty;self.position=first_position;board[&first_position]=Tile::Player;},Tile::Block=>{letmutcurrent_tile=Tile::Block;letmutcurrent_position=first_position;whilecurrent_tile==Tile::Block{ifletSome(next_position)=Self::get_next_position(current_position,direction){current_position=next_position;current_tile=board[¤t_position];matchcurrent_tile{Tile::Block=>{/* continue looking */},Tile::Empty=>{board[&self.position]=Tile::Empty;self.position=first_position;board[&first_position]=Tile::Player;board[¤t_position]=Tile::Block;},Tile::StaticBlock|Tile::Player|Tile::CommonBeast=>break,}}else{break;}}},Tile::Player|Tile::StaticBlock=>{},Tile::CommonBeast=>{todo!("The player ran into a beast and died");},}}}}
We had to add our new Tile option to two places.
First we added it to the blockchain seeker (I just came up with this term) and we’re saying in the code:
When you hit a Block moving, look into the direction of the movement until you find anything other than Block.
At the end if you find an Empty, move there.
If you find anything else, like StaticBlock or… CommonBeast then stop the search because the player is trying to
push a bunch of blocks against those things and you can’t push a beast much less a StaticBlock.
We can also just as easily use the _ (underscore) as a catch all at the end of the match to include all other Tiles
we might find but since you might add your own Beast later and may decided that your beast is totally pushable, it might
be best to stay explicit in our code for now.
The second place we added the new Tile option was in the first match which just checks what Tile you’re about to
move into.
If that happens to be a beast then, by all means, you should perish and re-spawn if you got enough lives left.
We shall implement that later, so for now we use the todo macro.
So everything compiles again and all is good in the (computer) world again.
Now we need to add our beasts onto our board.
We need to add beasts to our board and also make sure they move every second toward the player.
To top this all off, we also need to add different amounts of beasts per level when we start a new level.
So let’s start this from the back: add our beasts to our level config we return from our level module:
Now we need to use that new common_beast_count field in our new method in the board module.
Because we don’t want to hold the collection of beasts on the Board struct, we will have to somehow return more from
the new method than just Self.
This is a classic case of:
How do we return extra stuff from a constructor while keeping ergonomics clean and code idiomatic?
There are multiple ways you could do this:
You could create a new struct with two keys buffer and beasts and return that from the new function
You could create a separate method called generate_terrain and use that to generate both buffer and beasts Vec
and then in the new method accept a function argument for the buffer
You could simply return a Tuple from the new method with buffer and beasts
The most common (and fastest) way in Rust is 3 so let’s go with that.
userand::seq::SliceRandom;usecrate::{ANSI_CYAN,ANSI_GREEN,ANSI_RED,ANSI_RESET,ANSI_YELLOW,BOARD_HEIGHT,BOARD_WIDTH,Coord,TILE_SIZE,Tile,beasts::{Beast,CommonBeast},level::{Level,LevelConfig},};usestd::ops::{Index,IndexMut};#[derive(Debug)]pubstructBoard{pubbuffer: [[Tile;BOARD_WIDTH];BOARD_HEIGHT],}implIndex<&Coord>forBoard{typeOutput=Tile;fnindex(&self,coord: &Coord) -> &Self::Output{&self.buffer[coord.row][coord.column]}}implIndexMut<&Coord>forBoard{fnindex_mut(&mutself,coord: &Coord) -> &mutSelf::Output{&mutself.buffer[coord.row][coord.column]}}implBoard{pubfnnew() -> (Self,Vec<CommonBeast>){letmutbuffer=[[Tile::Empty;BOARD_WIDTH];BOARD_HEIGHT];letmutall_coords=(0..BOARD_HEIGHT).flat_map(|row|(0..BOARD_WIDTH).map(move|column|Coord{column,row})).filter(|coord|!(coord.column==0&&coord.row==0)).collect::<Vec<Coord>>();letmutrng=rand::rng();all_coords.shuffle(&mutrng);buffer[0][0]=Tile::Player;letLevelConfig{block_count,static_block_count,common_beast_count,}=Level::One.get_level_config();for_in0..block_count{letcoord=all_coords.pop().expect("We tried to place more blocks than there were available spaces on the board",);buffer[coord.row][coord.column]=Tile::Block;}for_in0..static_block_count{letcoord=all_coords.pop().expect("We tried to place more static blocks than there were available spaces on the board",);buffer[coord.row][coord.column]=Tile::StaticBlock;}letmutbeasts=Vec::with_capacity(common_beast_count);for_in0..common_beast_count{letcoord=all_coords.pop().expect("We tried to place more common beasts than there were available spaces on the board",);buffer[coord.row][coord.column]=Tile::CommonBeast;beasts.push(CommonBeast::new(coord));}(Self{buffer},beasts)}pubfnrender(&self) -> String{letmutoutput=format!("{ANSI_YELLOW}▛{}▜{ANSI_RESET}\n","▀".repeat(BOARD_WIDTH*TILE_SIZE));forrowsinself.buffer{output.push_str(&format!("{ANSI_YELLOW}▌{ANSI_RESET}"));fortileinrows{matchtile{Tile::Empty=>output.push_str(" "),Tile::Player=>{output.push_str(&format!("{ANSI_CYAN}◀▶{ANSI_RESET}"))},Tile::Block=>{output.push_str(&format!("{ANSI_GREEN}░░{ANSI_RESET}"))},Tile::StaticBlock=>{output.push_str(&format!("{ANSI_YELLOW}▓▓{ANSI_RESET}"))},Tile::CommonBeast=>{output.push_str(&format!("{ANSI_RED}├┤{ANSI_RESET}"))},}}output.push_str(&format!("{ANSI_YELLOW}▐{ANSI_RESET}\n"));}output.push_str(&format!("{ANSI_YELLOW}▙{}▟{ANSI_RESET}","▄".repeat(BOARD_WIDTH*TILE_SIZE)));output}}
Now we just need to fix up our new method in the Game struct:
Now our common beast will walk left until it hits the wall of the board when you call the advance method periodically.
But how do we call the advance method periodically?
We only make changes to the board and render when the player uses the keyboard.
Now we want the beasts to move every second.
We can’t do that within that while loop as that only executes when a key on the keyboard is pressed.
We will have to create a game loop that runs in the background and calls our advance method of each of our beasts
every second.
But we also have another problem: our call to read_exact is blocking which means within our game loop the code will
wait for it to be Ok before continuing which means our beasts would only move when keypresses are sent to stdin.
Also later we might want to listen to stdin but react to different keys that are pressed like in a help screen for
scrolling through pages.
Within our new method, we first create a channel for a u8 integer.
This channel constructor will return two things: a sender and a receiver.
Those will be our way to communicate between threads or more accurately, our way to send data from our stdin thread to
our main thread with our game.
Then we moved our stdin call from our play method to our new method and off we go creating our thread.
The thread constructor takes a closure which we tell to move all ownership to.
Only inside the thread do we lock stdin, move our buffer in and try to read from it.
This is all very similar to what we wrote in
part 1.
The difference is we now send the bytes we receive from stdin to our channel sender instead of matching against it
right away.
We add the channel receiver to our Game struct so we can listen to that channel anytime we need to and lastly we do
just that in our play method by changing our while loop to loop loop and call try_recv on the channel receiver.
That call is non-blocking and will allow us to do more in that loop like moving the beasts.
Everything still runs like before but we now have a separate thread dedicated just for listening to stdin.
We’ve added a tick to our game.
Every second we reset our last_tick variable to make sure our beasts don’t move too fast.
Within our tick, we iterate over each beast in our board and call the advance method.
Then we check if the result of the method is a Some and match against the Tile on the board the beast wants to go
to.
This way our beast modules are responsible for movements while our our game engine is responsible for checking the
correctness of the movements.
When we find the beast is going to an Empty tile, we set the previous position to Empty on the board, we store the
new position of the beast and set the new position to CommonBeast.
If we find that the beast is going into a Tile that is of type Player then we know the beast just killed the player
and we will have to implement the logic for the beast to kill us.
Lastly we ignore all other Tile types the beast might want to move into because those would be illegal moves and render
the board after all beasts position have been set.
But when we run our game, we get this:
cargo run
Compiling beast v0.1.0 (/Users/code/beast)
error[E0599]: no method named `advance` found for mutable reference `&mut common_beast::CommonBeast` in the current scope--> src/game.rs:79:13
|79| beast.advance(&self.board, &self.player.position)
|^^^^^^^method not found in `&mut CommonBeast`|= help: items from traits can only be used if the trait is in scope
help: trait `Beast` which provides `advance` is implemented but not in scope; perhaps you want to import it
|1+ use crate::beasts::beast_trait::Beast;|
Apparently the advance method of our CommonBeast isn’t public but since we’re using a trait implementation, instead
of making that method just pub (which would give is a “Syntax Error: Unnecessary visibility qualifier” error) we need
to import the trait into the game module just like the compiler tells us to.
OK, now that we have our beasts walk, how do we make sure they move in the right direction?
Especially for the common beast because that beast is meant to be less smart.
For the game play the first beasts we have to fight with are easier because they may get stuck behind blocks.
Later beasts, you’re supposed to add yourself outside this tutorial, should be smarter and gradually get more difficult
to defeat.
You may have heard about pathfinding before.
There are many ways to find a path through a maze but we want a simple way that isn’t too smart because our
CommonBeast should be easier to squash.
Let’s build our own!
Something very simple that “just” decides the first step in the direction of the player without actually analyzing if
there is a path in the first place.
At each turn, the beast has the option to choose from 8 possible moves because it can go diagonally as well as straight:
1
2
3
1
2
├┤
3
Depending on where the player is relative to the beast, the beast should go in as straight of a line to the player as it
can.
1
2
3
4
5
6
1
2
├┤
◀▶
3
Here the player is to the right of the beast.
The best move it could is a straight line to the player which in our case is position column: 3, row: 2.
But what if that position is blocked?
Then the beast would need to have a fallback option it could go to.
We have two equally good fallback options in our case: column: 3, row: 1 and column: 3, row: 3.
But what if those are blocked too?
So really, we need to take all 8 steps a beast can take and prioritize them relative to the position the player is at.
1
2
3
4
5
6
1
D
C
B
2
E
├┤
A
◀▶
3
D
C
B
The best position would be A followed by two positions marked as B which are equally good etc etc.
The same method would work for a different player position like top right:
1
2
3
4
5
6
1
C
B
A
◀▶
2
D
├┤
B
3
E
D
C
To simplify our matrix and thinking we could ignore how far the player is away and assume they are is right next to us:
1
2
3
1
D
C
B
2
E
├┤
◀▶
3
D
C
B
Of course the position the player is in should be our first priority.
The rest follows the same method as above.
1
2
3
1
C
B
◀▶
2
D
├┤
B
3
E
D
C
And we could draw the player in all 8 positions but you get the picture.
We have, at maximum, 8 positions the beast could move into next and we want to prioritize each of them so that when we
check on the board if these positions are legal moves, we first check the one that would get us to the player in a
straight line.
That was a lot of tables and numbers and words… let’s get back into code.
With all that in mind, we will have to look at the players position relative to the beasts and then match on the 8
possible outcomes.
Luckily Rust makes it easy for us to match on these 8 positions (It’s actually 9, because the center is one too but we
can safely ignore this one):
We are using the cmp method to compare the usize
position of the beasts own position and the player_position.
The cmp method will return an enum called Ordering which we
then match against.
It’s a lot so let’s just pick the top on and look at it.
The first item in our tuple we match is the column and the second is the row.
If column is Greater we know the player would have to be on the right of us.
If the column is Less, the player would have to be on the left and if the column is Equal then the player would be
either above or below us.
The same thing happens with our row.
If the row is Greater then the player is below us while Less means above us and Equal means on the same height.
The last position is where the player is in the same position as the beast which we have to include because matches in
Rust are exhaustive.
In reality that is a position our game engine should never allow to happen because that’s when a player died and should
be re-spawned.
We will add the unreachable macro into this arm to make that
clear.
Now that we have the match, let’s remember that we want to create a collection of all possible moves before iterating
over each one to find the return the best.
usestd::cmp::Ordering;usecrate::{Coord,beasts::Beast,board::Board};#[derive(Debug)]pubstructCommonBeast{pubposition: Coord,}implBeastforCommonBeast{fnnew(position: Coord) -> Self{Self{position}}fnadvance(&mutself,board: &Board,player_position: &Coord,) -> Option<Coord>{match(player_position.column.cmp(&self.position.column),player_position.row.cmp(&self.position.row),){(Ordering::Greater,Ordering::Greater)=>{/* player: right-bottom */// check if position 1 is legal then push into possible_moves
// check if position 2 is legal then push into possible_moves
// check if position 3 is legal then push into possible_moves
// check if position 4 is legal then push into possible_moves
// check if position 5 is legal then push into possible_moves
// check if position 6 is legal then push into possible_moves
// check if position 7 is legal then push into possible_moves
// check if position 8 is legal then push into possible_moves
},(Ordering::Greater,Ordering::Less)=>{/* player: right-top */// check if position 1 is legal then push into possible_moves
// check if position 2 is legal then push into possible_moves
// check if position 3 is legal then push into possible_moves
// check if position 4 is legal then push into possible_moves
// check if position 5 is legal then push into possible_moves
// check if position 6 is legal then push into possible_moves
// check if position 7 is legal then push into possible_moves
// check if position 8 is legal then push into possible_moves
},(Ordering::Greater,Ordering::Equal)=>{/* player: right_middle */// check if position is legal then push into possible_moves
// ...
},(Ordering::Less,Ordering::Greater)=>{/* player: left_bottom */},(Ordering::Less,Ordering::Less)=>{/* player: left_top */},(Ordering::Less,Ordering::Equal)=>{/* player: left_middle */},(Ordering::Equal,Ordering::Greater)=>{/* player: middle_bottom */},(Ordering::Equal,Ordering::Less)=>{/* player: middle_top */},(Ordering::Equal,Ordering::Equal)=>{/* player: same position */unreachable!();},}None}}
Inside each branch of our match we will have to check if each position is a legal move (is it inside the board, is it
an Empty Tile?)
We have to use a Vec here because at compile time we don’t know how many items will be in our collection.
That’s because not all of our 8 positions might be possible because the beast might be standing next to the end of the
board.
It could even stand right in a corner of the board which would give us only 3 possible moves:
1
2
3
1
▛
▀▀
▀▀
2
▌
├┤
3
▌
So before we can push into possible_moves Vec, we have to check if the position is valid.
Since we would have to do that in each and every branch, we might as well prepare these positions outside the match so
that inside each arm of our match we can just push them in.
Because in Rust everything is an expression, we use the match to return an array (stack allocated) of Option<Coord>.
Then we flatten that array and collect it
into our possible_moves Vec, omitting any out-of-bounds options (None).
We have to use a Vec because we can’t know the size of this collection at compile time.
Also flatten on Option is a zero-cost abstraction since it’s specialized by the compiler to check the discriminant
without virtual dispatch or any heap allocations.
Now we have a possible_moves Vec with prioritized moves respective to the players position.
All we need to do now is iterate over those moves and return the first Coord that contains an Empty Tile on the
board:
Ok, beasts are moving now.
One last thing though: let’s use clippy!
Clippy is a tool that ships with cargo and is super helpful,
especially when starting out with Rust.
Let’s run it now:
cargo clippy
Checking beast v0.1.0 (/Users/code/beast)
warning: variants `Two` and `Three` are never constructed
--> src/level.rs:10:2
|8 | pub enum Level {
| ----- variants in this enum9 | One,
10 | Two,
|^^^11 | Three,
|^^^^^ |
= note: `Level` has a derived impl for the trait `Debug`, but this is intentionally ignored during dead code analysis
= note: `#[warn(dead_code)]` on by default
warning: manual implementation of `Iterator::find`
--> src/beasts/common_beast.rs:238:3
|238 |/ for next_move in possible_moves {
239 || if board[&next_move] == Tile::Empty {
240 || return Some(next_move);
... |244 || None
||____________^ help: replace with an iterator: `possible_moves.into_iter().find(|&next_move| board[&next_move] == Tile::Empty)` |
= help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#manual_find
= note: `#[warn(clippy::manual_find)]` on by default
warning: `beast` (bin "beast") generated 2 warnings (run `cargo clippy --fix --bin "beast"` to apply 1 suggestion)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.09s
The first warning we have seen a lot and can ignore for now.
It just means we haven’t implemented the other levels yet.
We will get there soon.
But the other warning is interesting!
Clippy suggests that instead of doing our for loop and then our return of None, we could express it more concisely:
That IS much better and more idiomatic to Rust.
Great suggestion, as almost always, Clippy!
Our beast now move toward us with the simplest pathfinding algorithm I could come up with.
And to my surprise, the result is pretty decent as beasts rarely get stuck:
Well done us!
In the last part of this tutorial series
we will give our player lives, the ability to re-spawn and implement a help screen.