What we have now is pretty much a playable game already. The next two parts will take it a big step forward by focusing on progression. We’ll start with dungeon levels, then character progression and finally on updating the monster and item placement.
Second floor please
A staple of roguelikes is the stairs, which the player must find to
advance to the next dungeon level. We will start by placing them, when
generating a level. Right at the end of make_map
:
// create stairs at the center of the last room
let (last_room_x, last_room_y) = rooms[rooms.len() - 1].center();
let mut stairs = Object::new(last_room_x, last_room_y, '<', "stairs", WHITE, false);
stairs.always_visible = true;
objects.push(stairs);
As you can see, it’s just a regular object! We must now let the player
go down the stairs when standing on them and presses the '<' key. It’s
easy to add this check at the end of handle_keys
:
(Key { code: Text, .. }, "<", true) => {
// go down stairs, if the player is on them
let player_on_stairs = objects
.iter()
.any(|object| object.pos() == objects[PLAYER].pos() && object.name == "stairs");
if player_on_stairs {
next_level(tcod, game, objects);
}
DidntTakeTurn
}
The most important bit is the next_level
function. We need to
generate a brand new level when the player goes down and move player
onto it. We’ll also heal the player because we’re feeling generous!
/// Advance to the next level
fn next_level(tcod: &mut Tcod, game: &mut Game, objects: &mut Vec<Object>) {
game.messages.add(
"You take a moment to rest, and recover your strength.",
VIOLET,
);
let heal_hp = objects[PLAYER].fighter.map_or(0, |f| f.max_hp / 2);
objects[PLAYER].heal(heal_hp);
game.messages.add(
"After a rare moment of peace, you descend deeper into \
the heart of the dungeon...",
RED,
);
game.dungeon_level += 1;
game.map = make_map(objects);
initialise_fov(tcod, &game.map);
}
You can try it out now. We do generate stairs and let the player go deeper, but you’ll notice that the items and monsters from the old level are still here — sometimes lodged in a wall.
The reason we’re seeing old objects is because we’ve never removed
them from the objects
Vec!
So we need to remove everything except for the Player. Put this in
make_map
right after we create the empty map:
// Player is the first element, remove everything else.
// NOTE: works only when the player is the first object!
assert_eq!(&objects[PLAYER] as *const _, &objects[0] as *const _);
objects.truncate(1);
Luckily, Vec has the truncate method, which leaves the first n elements and removes everything else. Since our player is always the first object in the list, this just works.
The assert is there to make sure that the first object is indeed the
player — the program will panic otherwise, making sure you address it
if you change your game layout at some point. By the way, this is how
you test for a pointer equivalence in Rust: you convert ordinary Rust
references to raw pointers (*const Object
in this case) and compare
those. This is safe because we never dereference them. As a bonus, the
compiler can infer the type (Object
) so you can just say _
and it
will fill it in.
If you want to let the player go back up, you may want to keep track of the map and objects at the previous levels so that they stay the same. |
We’ll want to keep track of the dungeon level the player is on. Let’s
add a variable to Game
and set it to one when we start the game and
increase it when we go deeper:
struct Game {
map: Map,
messages: Messages,
inventory: Vec<Object>,
dungeon_level: u32, (1)
}
1 | New field: dungeon_level |
in new_game
:
let mut game = Game {
// generate map (at this point it's not drawn to the screen)
map: make_map(&mut objects),
messages: Messages::new(),
inventory: vec![],
dungeon_level: 1, (1)
};
1 | Set dungeon_level to 1 |
and in next_level
before calling make_map
:
game.dungeon_level += 1;
Now we can display it in the GUI. Add this line to render_all
after
calling render_bar
:
tcod.panel.print_ex(
1,
3,
BackgroundFlag::None,
TextAlignment::Left,
format!("Dungeon level: {}", game.dungeon_level),
);
Finally, it would be great to always show the stairs once discovered, so the player can explore the rest of the map before going down. So let’s allow some objects to be always visible as long as they’re on a tile that’s already been explored.
We can add always_visible
to Object
:
struct Object {
x: i32,
y: i32,
// ...
always_visible: bool,
}
Let’s initialise it to false
in Object::new
:
pub fn new(x: i32, y: i32, char: char, name: &str, color: Color, blocks: bool) -> Self {
Object {
x: x,
y: y,
// ...
always_visible: false,
}
}
Now update render_all
to take it into account. When building the
to_draw
vector, let’s update the filter
test to this:
let mut to_draw: Vec<_> = objects
.iter()
.filter(|o| {
tcod.fov.is_in_fov(o.x, o.y)
|| (o.always_visible && game.map[o.x as usize][o.y as usize].explored)
})
.collect();
We keep the old is_in_fov
test, but now we can also show the object
if it’s always visible and on an explored
tile.
So let’s set always_visible = true
to stairs in make_map
:
stairs.always_visible = true;
you will also have to add mut
to let stairs
a line above.
And let’s do the same for items, too! In place_objects
before
objects.push(item)
:
let dice = rand::random::<f32>();
let mut item = if dice < 0.7 { (1)
// create a healing potion (70% chance)
let mut object = Object::new(x, y, '!', "healing potion", VIOLET, false);
object.item = Some(Item::Heal);
object
}
...
item.always_visible = true;
objects.push(item);
1 | Item must be mut now |
Character progression
With being able to go into deeper levels, the player character now
feels a bit static. Let’s track their experience and allow to
level up. We’ll put a new xp
field into the Fighter
struct:
struct Fighter {
max_hp: i32,
hp: i32,
defense: i32,
power: i32,
xp: i32, (1)
on_death: DeathCallback,
}
1 | Added the xp field |
When setting the orc and trolls' Fighter
component in
place_objects
, we’ll add 35 and 100 xp
respectively. Feel free to
plug your own values here.
let mut monster = if rand::random::<f32>() < 0.8 {
// 80% chance of getting an orc
// create an orc
let mut orc = Object::new(x, y, 'o', "orc", DESATURATED_GREEN, true);
orc.fighter = Some(Fighter {
max_hp: 10,
hp: 10,
defense: 0,
power: 3,
xp: 35, (1)
on_death: DeathCallback::Monster,
});
orc.ai = Some(Ai::Basic);
orc
} else {
// create a troll
let mut troll = Object::new(x, y, 'T', "troll", DARKER_GREEN, true);
troll.fighter = Some(Fighter {
max_hp: 16,
hp: 16,
defense: 1,
power: 4,
xp: 100, (2)
on_death: DeathCallback::Monster,
});
troll.ai = Some(Ai::Basic);
troll
};
1 | Defeating an orc gives you 35 XP |
2 | Defeating a troll gives you 100 XP |
We’ll have to set player’s XP in new_game
to something as well.
Let’s put a 0
in and we’ll use it to track player’s experience.
player.fighter = Some(Fighter {
max_hp: 30,
hp: 30,
defense: 2,
power: 5,
xp: 0, (1)
on_death: DeathCallback::Player,
});
1 | Added xp |
Now update take_damage
to return the experience points when a
monster is killed:
pub fn take_damage(&mut self, damage: i32, game: &mut Game) -> Option<i32> { (1)
// apply damage if possible
if let Some(fighter) = self.fighter.as_mut() {
if damage > 0 {
fighter.hp -= damage;
}
}
// check for death, call the death function
if let Some(fighter) = self.fighter {
if fighter.hp <= 0 {
self.alive = false;
fighter.on_death.callback(self, game);
return Some(fighter.xp); (2)
}
}
None (3)
}
1 | May return a number of XP if take_damage killed the monster |
2 | We did kill the monster, return its XP |
3 | We did not kill the monster, don’t return anything |
Now in attack
, when an attacker kills their target, let’s increase their
xp by replacing the target.take_damage(…)
call with:
if let Some(xp) = target.take_damage(damage, game) {
// yield experience to the player
self.fighter.as_mut().unwrap().xp += xp;
}
And we need to do the same in the two other places we’re calling
take_damage
. First in cast_lightning
:
if let Some(xp) = objects[monster_id].take_damage(LIGHTNING_DAMAGE, game) {
objects[PLAYER].fighter.as_mut().unwrap().xp += xp;
}
The cast_fireball
function is going to be slightly trickier because
we don’t want to give the player XP for burning themself and we can’t
modify the player inside the loop because the objects
Vec is already
mutably borrowed.
So, whenever we get some XP from take_damage
, we’ll add it to a
variable and then give it all to the player afterwards:
let mut xp_to_gain = 0; (1)
for (id, obj) in objects.iter_mut().enumerate() { (2)
if obj.distance(x, y) <= FIREBALL_RADIUS as f32 && obj.fighter.is_some() {
game.messages.add(
format!(
"The {} gets burned for {} hit points.",
obj.name, FIREBALL_DAMAGE
),
ORANGE,
);
if let Some(xp) = obj.take_damage(FIREBALL_DAMAGE, game) {
if id != PLAYER { (3)
// Don't reward the player for burning themself!
xp_to_gain += xp;
}
}
}
}
objects[PLAYER].fighter.as_mut().unwrap().xp += xp_to_gain; (4)
1 | Keep track of all the XP player should receive |
2 | Use enumerate to get the object’s index as well |
3 | Use the index to make sure we don’t include the player’s XP |
4 | Give all the accumulated XP to the player |
Ok, so what can the player do with all this experience they’re getting now? Level up of course!
First, we’ll need to keep track of player’s level. We’ll add it as
another field to Object
(so that monsters and items can have levels
too if we decide to use them later), but you could add it into Game
just as easily.
struct Object {
// ...
level: i32,
}
And initialise it to 1
in Object’s new
method:
pub fn new(x: i32, y: i32, char: char, name: &str, color: Color, blocks: bool) -> Self {
Object {
// ...
level: 1,
}
}
Typically, you need more experience to level up the higher you get.
Let’s set the starting point to 350 xp and then require 150 for every
new level. So the formula is 200 + player.level * 150
.
Add constants so it can be easily tweaked later:
// experience and level-ups
const LEVEL_UP_BASE: i32 = 200;
const LEVEL_UP_FACTOR: i32 = 150;
Now the function that level’s the player up if they have enough experience:
fn level_up(tcod: &mut Tcod, game: &mut Game, objects: &mut [Object]) {
let player = &mut objects[PLAYER];
let level_up_xp = LEVEL_UP_BASE + player.level * LEVEL_UP_FACTOR;
// see if the player's experience is enough to level-up
if player.fighter.as_ref().map_or(0, |f| f.xp) >= level_up_xp {
// it is! level up
player.level += 1;
game.messages.add(
format!(
"Your battle skills grow stronger! You reached level {}!",
player.level
),
YELLOW,
);
// ... TODO increase players's stats!
}
}
So, if the player has enough experience, we’ll increase their level and print out a message.
But let’s give them an actual gameplay bonus. Using the menu
function, we’ll give them three choices: to increase the HP, attack or
defense.
Put this in place of the TODO comment:
let fighter = player.fighter.as_mut().unwrap();
let mut choice = None;
while choice.is_none() {
// keep asking until a choice is made
choice = menu(
"Level up! Choose a stat to raise:\n",
&[
format!("Constitution (+20 HP, from {})", fighter.max_hp),
format!("Strength (+1 attack, from {})", fighter.power),
format!("Agility (+1 defense, from {})", fighter.defense),
],
LEVEL_SCREEN_WIDTH,
&mut tcod.root,
);
}
fighter.xp -= level_up_xp;
match choice.unwrap() {
0 => {
fighter.max_hp += 20;
fighter.hp += 20;
}
1 => {
fighter.power += 1;
}
2 => {
fighter.defense += 1;
}
_ => unreachable!(),
}
We’ll need to add the new constant on top of the file and then it should compile:
const LEVEL_SCREEN_WIDTH: i32 = 40;
Now we can call level_up
in the main loop (in play_game
) after
tcod.root.flush()
:
// level up if needed
level_up(tcod, game, objects);
So the player can now level up, but it would be great to show the
current stats somewhere. Let’s display a little message box when the
C
key is pressed. In handle_keys
:
(Key { code: Text, .. }, "c", true) => {
// show character information
let player = &objects[PLAYER];
let level = player.level;
let level_up_xp = LEVEL_UP_BASE + player.level * LEVEL_UP_FACTOR;
if let Some(fighter) = player.fighter.as_ref() {
let msg = format!(
"Character information
Level: {}
Experience: {}
Experience to level up: {}
Maximum HP: {}
Attack: {}
Defense: {}",
level, fighter.xp, level_up_xp, fighter.max_hp, fighter.power, fighter.defense
);
msgbox(&msg, CHARACTER_SCREEN_WIDTH, &mut tcod.root);
}
DidntTakeTurn
}
This will build up a multiline string that we use msgbox to show. We’ll need to define the new constant at the top of the file and then it should all work:
const CHARACTER_SCREEN_WIDTH: i32 = 30;
It would also be nice if we showed how much XP did the player get for
slaying a monster. We can modify the log message in monster_death
:
// transform it into a nasty corpse! it doesn't block, can't be
// attacked and doesn't move
game.messages.add(
format!(
"{} is dead! You gain {} experience points.",
monster.name,
monster.fighter.unwrap().xp
),
ORANGE,
);
Finally, completely unrelated to the character progression, but let’s add diagonal movement and sleep command using the keys on the numpad.
The key codes for the numpad keys are NumPad0
to NumPad9
. So in
handle_keys
, we’ll replace the existing movement code with this:
// movement keys
(Key { code: Up, .. }, _, true) | (Key { code: NumPad8, .. }, _, true) => {
player_move_or_attack(0, -1, game, objects);
TookTurn
}
(Key { code: Down, .. }, _, true) | (Key { code: NumPad2, .. }, _, true) => {
player_move_or_attack(0, 1, game, objects);
TookTurn
}
(Key { code: Left, .. }, _, true) | (Key { code: NumPad4, .. }, _, true) => {
player_move_or_attack(-1, 0, game, objects);
TookTurn
}
(Key { code: Right, .. }, _, true) | (Key { code: NumPad6, .. }, _, true) => {
player_move_or_attack(1, 0, game, objects);
TookTurn
}
(Key { code: Home, .. }, _, true) | (Key { code: NumPad7, .. }, _, true) => {
player_move_or_attack(-1, -1, game, objects);
TookTurn
}
(Key { code: PageUp, .. }, _, true) | (Key { code: NumPad9, .. }, _, true) => {
player_move_or_attack(1, -1, game, objects);
TookTurn
}
(Key { code: End, .. }, _, true) | (Key { code: NumPad1, .. }, _, true) => {
player_move_or_attack(-1, 1, game, objects);
TookTurn
}
(Key { code: PageDown, .. }, _, true) | (Key { code: NumPad3, .. }, _, true) => {
player_move_or_attack(1, 1, game, objects);
TookTurn
}
(Key { code: NumPad5, .. }, _, true) => {
TookTurn // do nothing, i.e. wait for the monster to come to you
}
Now we can use arrows and numpad to move around. And pressing 5
will
let you skip a turn and have the monster come to you.
Here’s the complete code so far.
Continue to the next part.