Populating the dungeon
We have an explorable dungeon now. Yeah! But when you actually go through it, it feels a bit… boring. It’s just empty rooms connected to more empty rooms. Let’s add some monsters!
This isn’t going to be as bad thanks to our object system. We create
an object for each monster and add it to the objects
list. So all we
need to do is to create a few monsters in random locations for each
room.
Let’s add a function that takes a room and does exactly that.
fn place_objects(room: Rect, objects: &mut Vec<Object>) {
// choose random number of monsters
let num_monsters = rand::thread_rng().gen_range(0, MAX_ROOM_MONSTERS + 1);
for _ in 0..num_monsters {
// choose random spot for this monster
let x = rand::thread_rng().gen_range(room.x1 + 1, room.x2);
let y = rand::thread_rng().gen_range(room.y1 + 1, room.y2);
let mut monster = if rand::random::<f32>() < 0.8 { // 80% chance of getting an orc
// create an orc
Object::new(x, y, 'o', colors::DESATURATED_GREEN)
} else {
Object::new(x, y, 'T', colors::DARKER_GREEN)
};
objects.push(monster);
}
}
We’ll define the MAX_ROOM_MONSTERS
constant at the top of the file:
const MAX_ROOM_MONSTERS: i32 = 3;
Calling rand::random::<f32>()
will produce an f32
number between
0.0
and 1.0
. 80% of that is 0.8
.
As you can see, we’re defining orcs and trolls here, but you can
do anything you want! And as we add more properties to Object
, you
can set them all here and create any monster (or npc or item) your
heart desires.
later on, we’ll use a choice table to make the code cleaner but
for now you can just extend the if random() … else block to add
more variety.
|
To actually place the monsters in each room, we will call this function
right after create_room
in make_map
:
// add some content to this room, such as monsters
place_objects(new_room, objects);
And update the signature of make_map
to take in a mutable reference
to our list of objects. We’ll also stop returning the
starting_position
. It can be accessed via objects[PLAYER].pos()
:
fn make_map(objects: &mut Vec<Object>) -> Map {
...
map
}
Let’s set the player’s position when we generate the first room:
if rooms.is_empty() {
// this is the first room, where the player starts at
objects[PLAYER].set_pos(new_x, new_y);
} else {
...
}
When we call make_map
pass it a mutable reference to the entire objects
list:
let mut game = Game {
// generate map (at this point it's not drawn to the screen)
map: make_map(&mut objects),
};
Let’s also remove the dummy NPC from the initial objects list. We won’t need it anymore:
// the list of objects with just the player
let mut objects = vec![player];
Getting hold of the Player’s position
Before we move further, there are two things we can do to make working with the player object easier and also to get and set the position of an object.
We’ve already typed objects[0]
a few times to refer to the player.
To make it a bit clearer, we’ll define a new constant PLAYER
with
the value 0
. Put this among the other constants:
// player will always be the first object
const PLAYER: usize = 0;
And now we can replace every usage of objects[0]
with
objects[PLAYER]
. We have one in the main
function:
while !root.window_closed() {
...
// render the screen
let fov_recompute = previous_player_position != (objects[PLAYER].pos()); (1)
...
}
1 | objects[0] → objects[PLAYER] |
And one more in render_all
:
if fov_recompute {
// recompute FOV if needed (the player moved or something)
let player = &objects[PLAYER]; (1)
tcod.fov
.compute_fov(player.x, player.y, TORCH_RADIUS, FOV_LIGHT_WALLS, FOV_ALGO);
}
1 | objects[0] → objects[PLAYER] |
It’s a bit longer to type, but the intent is much clearer.
Next, add these two methods to Object
:
pub fn pos(&self) -> (i32, i32) {
(self.x, self.y)
}
pub fn set_pos(&mut self, x: i32, y: i32) {
self.x = x;
self.y = y;
}
These give us a shorthand for getting or setting both coordinates (x
and y
) at once. This will again simplify some code in main
, the
move_by
method of Object as well as setting the player’s initial
position in make_map
.
Blocking objects
If you tried to walk up to a monster, you’d see that the player would walk right through! That’s clearly not what we want to happen in general. Plus, we don’t want multiple monsters standing on the same tile.
But there are other options (scrolls, potions) that should not block the tile they’re on.
Does it block?
Let’s update Object
with information whether it blocks the player
or not. And give each object a name while we’re at it. Put this in the
Object
struct definition:
struct Object {
x: i32,
y: i32,
char: char,
color: Color,
name: String, (1)
blocks: bool, (2)
alive: bool, (3)
}
1 | New field: name |
2 | New field: blocks |
3 | New field: alive |
And change the new
method on Object
to:
pub fn new(x: i32, y: i32, char: char, name: &str, color: Color, blocks: bool) -> Self {
Object {
x: x,
y: y,
char: char,
color: color,
name: name.into(),
blocks: blocks,
alive: false,
}
}
All our objects are alive at the moment, but soon we’ll add
items, scrolls, stairs, etc. and the balance will shift. Better set
things alive explicitly than turn it off. It’s easy to see when you’ve
missed setting alive = true but hard to do the opposite.
|
Now we’ll create a function that tests if a tile is blocked — whether due to a wall or an object blocking it.
fn is_blocked(x: i32, y: i32, map: &Map, objects: &[Object]) -> bool {
// first test the map tile
if map[x as usize][y as usize].blocked {
return true;
}
// now check for any blocking objects
objects
.iter()
.any(|object| object.blocks && object.pos() == (x, y))
}
It takes the coordinates we want to check and we must also pass in the map and objects.
Ownership woes
Now we’d like to use is_blocked
in the move_by
method to make sure
an object never moves onto a blocked tile.
If we just put the !is_blocked(self.x + dx, self.y + dy, map,
objects)
check into the method and add objects
as a function
parameter, Rust will not let us use it.
This method will compile just fine, but if you try calling it in
handle_keys
, the program will not compile:
objects[PLAYER].move_by(1, 0, &map, &objects);
Rust will complain that it cannot have a mutable and an immutable borrow at once.
To guarantee memory safety and no data races, Rust’s references (&
and &mut
) have a few rules. One of them is that when you have a
mutable borrow, you can’t have any other mutable or immutable borrows
into the same data.
And that is exactly what’s happening here. The signature of the
move_by
method is:
fn move_by(&mut self, dx: i32, dy: i32, map: &Map, objects: &[Object])
We need &Map
and &[Object]
because they both need to be passed to
is_blocked
. But, we also need the &mut self
at the beginning to be
able to modify the position of the object we’re moving.
And therein lies the problem, since all objects (including the one
we’re calling move_by
on) are in the objects
vec, as soon as we
mutably borrow one part of it, Rust locks the entire vec. The line
above is essentially equivalent to this:
let player = &mut objects[PLAYER]; (1) (2)
let borrowed_objects = &objects; (3)
player.move_by(1, 0, &map, objects) (4)
1 | Get a mutable borrow of the player object |
2 | That will treat the whole objects vec as mutably borrowed |
3 | Try to immutably borrow objects — fails because it’s already borrowed |
4 | We don’t even get here because of the double borrow issue |
There’s multiple ways to solve this, but the easiest is to turn the method into a plain function and pass in the object index instead of a reference:
/// move by the given amount, if the destination is not blocked
fn move_by(id: usize, dx: i32, dy: i32, map: &Map, objects: &mut [Object]) {
let (x, y) = objects[id].pos();
if !is_blocked(x + dx, y + dy, map, objects) {
objects[id].set_pos(x + dx, y + dy);
}
}
Now we no longer have the problem, because we first get the
object’s position (immutable borrow that ends immediately), then call
is_blocked
with objects
(again, immutable borrow that ends right
after the call) and finally, with no borrows to burden us, we set the
position.
This is what Rust people sometimes refer to as "fighting the borrow checker". When you start with the language, you’ll likely encounter a lot of these situations. As you get more experienced, though, you’ll learn which patterns will cause trouble and structure your code differently.
Most of the time, Rust will catch things that could result in memory or threading issues in other languages. But sometimes (such as in our case here), it can’t tell whether the operation is okay or not and so it rather errs on the side of safety.
You can read more in the Rust book’s chapters on ownership and borrowing:
All’s well
So after this interlude, the objects (including the player) can no longer move into a tile occupied by another blocking object.
Next, make sure we don’t place two blocking objects onto the same
tile. In place_objects
, we’ll check whether the tile is free before
placing a new monster:
// only place it if the tile is not blocked
if !is_blocked(x, y, map, objects) {
// generate the monster
}
That means we now have to pass the map
to place_objcets
as well:
fn place_objects(room: Rect, map: &Map, objects: &mut Vec<Object>) {
...
}
And of course we have to pass map
when we call place_objecs
in
make_map
too:
// add some content to this room, such as monsters
place_objects(new_room, &map, objects);
Since objects have two new properties, we need to pass them along to any code that creates one. Update the player creation to:
// create object representing the player
let mut player = Object::new(0, 0, '@', "player", WHITE, true);
player.alive = true;
And update the code that creates the monsters:
let mut monster = if rand::random::<f32>() < 0.8 {
// 80% chance of getting an orc
// create an orc
Object::new(x, y, 'o', "orc", DESATURATED_GREEN, true)
} else {
// create a troll
Object::new(x, y, 'T', "troll", DARKER_GREEN, true)
};
Let’s make the monsters alive as well. Right before objects.push(monster)
:
monster.alive = true;
objects.push(monster);
And in handle_keys
, we’ll change the movement code from
player.move_by(0, -1, game)
to:
move_by(PLAYER, 0, -1, game, objects)
Player actions
Last stop before we get to the actual combat system! Our input system has a fatal flaw: player actions (movement, combat) and other keys (fullscreen, other options) are handled the same way. We need to separate them. This way, if the player pauses or dies he can’t move or fight, but can press other keys. We also want to know if the player’s input means he finished his turn or not; changing to fullscreen shouldn’t count as a turn. I know they’re just simple details - but the game would be incredibly annoying without them!
Let’s define high-level actions from the player that we can control the game loop with:
#[derive(Clone, Copy, Debug, PartialEq)]
enum PlayerAction {
TookTurn,
DidntTakeTurn,
Exit,
}
(deriving PartialEq
lets us use ==
and !=
to compare the enums together)
Change handle_keys
to return PlayerAction
instead of bool
.
fn handle_keys(tcod: &mut Tcod, game: &Game, objects: &mut Vec<Object>) -> PlayerAction {
...
}
We’re going to be using the enum values heavily in handle_keys
, so
let’s import them on top of the function:
use PlayerAction::*;
And then, in the code for fullscreen, return DidntTakeTurn
:
Key {
code: Enter,
alt: true,
..
} => {
// Alt+Enter: toggle fullscreen
let fullscreen = root.is_fullscreen();
root.set_fullscreen(!fullscreen);
DidntTakeTurn
}
Have the Escape
code path return Exit
:
Key { code: Escape, .. } => Exit, // exit game
And PlayerAction::TookTurn
to all the movement actions and
PlayerAction::DidntTakeTurn
to the catch-all at the end.
Key { code: Up, .. } => {
move_by(PLAYER, 0, -1, map, objects);
TookTurn
}
// and so on for Down, Left and Right
_ => DidntTakeTurn,
This will ensure that pressing an unknown key will not do anything and as we’ll add other actions, such as picking up items, accessing inventory, etc. we’ll have an easy way of saying whether they take a turn or not — or even being more dynamic than that — just opening an inventory may not cost anything but using an item from it could.
And now let’s only allow things like movement when the game is still going on. You wouldn’t want the player’s corpse to walk around after death (or maybe you would! There’s a game idea.), but you may still allow things like full screen, exiting the game or even a read-only view into the inventory.
Let’s update our match to include the player_alive
, too:
let key = tcod.root.wait_for_keypress(true);
let player_alive = objects[PLAYER].alive;
match (key, key.text(), player_alive) {
// key handling
}
We have also added key.text()
.
It’s a method that lets us read a textual representation of the pressed key.
This will be helpful on when we want to handle letters or keys such as >
.
The position of these depends on the keyboard layout our player has set up.
We can’t just check for Shift
+ ,
because many non-English keyboards have the greater-than key in a completely different place!
So we’ll pass the text
in alongside the key and the player_alive
and just ignore it for now.
Now instead of just matching on the key alone, we have to take these two values into consideration as well. Fullscreen and exit on Escape should work whether the player is alive or dead, so change them to:
(
Key {
code: Enter,
alt: true,
..
},
_,
_,
) => {
// Alt+Enter: toggle fullscreen
let fullscreen = tcod.root.is_fullscreen();
tcod.root.set_fullscreen(!fullscreen);
DidntTakeTurn
}
(Key { code: Escape, .. }, _, _) => Exit, // exit game
We’re taking three values in a tuple (key, text, player_alive)
now and ignoring the latter two.
For movement, we only want it to work when the player is alive so:
// movement keys
(Key { code: Up, .. }, _, true) => {
player_move_or_attack(0, -1, game, objects);
TookTurn
}
(Key { code: Down, .. }, _, true) => {
player_move_or_attack(0, 1, game, objects);
TookTurn
}
(Key { code: Left, .. }, _, true) => {
player_move_or_attack(-1, 0, game, objects);
TookTurn
}
(Key { code: Right, .. }, _, true) => {
player_move_or_attack(1, 0, game, objects);
TookTurn
}
There are other ways to handle this. We could use use the if
syntax in the match arm (so e.g. Key { code: Down, .. } if game_state
== Playing ⇒ // move player ) or even get rid of match entirely and
use if/else statements just like in Python. However, I find this
easier to read and it makes sure we never forget to handle the game
state when we add a new key.
|
And now we need to go back to the main loop and handle
PlayerAction
there. Change the end of the loop to:
// handle keys and exit game if needed
previous_player_position = objects[PLAYER].pos();
let player_action = handle_keys(&mut tcod, &game, &mut objects);
if player_action == PlayerAction::Exit {
break;
}
Fighting orderly
This part is already running long, so we won’t actually implement combat here (that will happen in the next part), but we’ll make sure that the player and the monsters take turns to act.
// let monsters take their turn
if objects[PLAYER].alive && player_action != PlayerAction::DidntTakeTurn {
for object in &objects {
// only if object is not player
if (object as *const _) != (&objects[PLAYER] as *const _) {
println!("The {} growls!", object.name);
}
}
}
The as *const _
bit is there to do a pointer comparison. Rust’s
equality operators (==
and !=
) test for value equality, but we
haven’t implemented that for Object
and we don’t care anyway — we
just want to make sure to not process player here.
The println!
is just the debug message. You’ll see it in the console
where you write cargo run --release
to run your game. In the next
part we’ll add an AI routine to move and attack and later on an
in-game message log where we can print stuff to the player.
Right now, when a player tries to move (bump) into a monster, nothing
happens. Let’s interpret that as an attack. We’ll add a new function
called player_move_or_attack
and use it instead of move_by
in
handle_keys
.
Replace all calls to:
move_by(0, -1, map, objects);
With:
player_move_or_attack(1, 0, game, objects)
Now let’s write the function itself:
fn player_move_or_attack(dx: i32, dy: i32, game: &Game, objects: &mut [Object]) {
// the coordinates the player is moving to/attacking
let x = objects[PLAYER].x + dx;
let y = objects[PLAYER].y + dy;
// try to find an attackable object there
let target_id = objects.iter().position(|object| object.pos() == (x, y));
// attack if target found, move otherwise
match target_id {
Some(target_id) => {
println!(
"The {} laughs at your puny efforts to attack him!",
objects[target_id].name
);
}
None => {
move_by(PLAYER, dx, dy, &game.map, objects);
}
}
}
The position
method on an iterator runs a test on each object and as
soon as it finds one, it returns its index in the collection (in our
case a vec of Object
).
It’s possible no match will be found, so it actually returns
Option<usize>
here.
We then test whether we have found a target at that position (in which case we know its index), and either print out a message or just move into that place.
And that’s it! Test it out. No one’s dealing any damage, but the game now detects when you’re trying to attack a monster. And you can see the monsters taking their turns after you.
Here’s the complete code so far.
Guess what’s next?
Continue to the next part.