The Components
TODO: talk about composition vs. inheritance and how this isn’t a real ECS whatever that means…
Our components will be plain structs
with related bits of data and
not much else. Each Object
will have some (or all or none) of the
components attached and that will drive their behaviour. Only things
with the Fighter
component will be able to attack or be attacked,
for example.
Let’s create the Fighter
component. It will have hit points, maximum
hit points (for healing), defense and attack power.
// combat-related properties and methods (monster, player, NPC).
#[derive(Clone, Copy, Debug, PartialEq)]
struct Fighter {
max_hp: i32,
hp: i32,
defense: i32,
power: i32,
}
Next is the component for monster artificial intelligence. For now, it will not carry any data, but we’ll soon remedy that.
#[derive(Clone, Debug, PartialEq)]
enum Ai {
Basic,
}
And update the Object
definition:
struct Object {
x: i32,
y: i32,
char: char,
color: Color,
name: String,
blocks: bool,
alive: bool,
fighter: Option<Fighter>, (1)
ai: Option<Ai>, (2)
}
1 | Added the Fighter component |
2 | Added the Ai component |
and Object::new
:
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,
fighter: None, (1)
ai: None, (2)
}
}
1 | Initialise the Fighter component to None |
2 | Initialise the Ai component to None |
This means that the newly-created objects will not have any components. We can add them ourselves, though!
First the player:
player.fighter = Some(Fighter {
max_hp: 30,
hp: 30,
defense: 2,
power: 5,
});
(you’ll need to make the player
variable mutable because we’re
changing it now)
And next the monsters. Each monster will get a Fighter
component as
well as the Ai
one. In place_objects
where the monsters are
defined:
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,
});
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,
});
troll.ai = Some(Ai::Basic);
troll
};
AI
We went through all this trouble and yet nothing happens? Let’s fix that by actually using our newly-minted components! The monsters have been growling for too long and are ready to fight now.
We’ll start by creating a function that will cause an object (monster, usually) to move towards a position (the player’s coordinates, usually).
fn move_towards(id: usize, target_x: i32, target_y: i32, map: &Map, objects: &mut [Object]) {
// vector from this object to the target, and distance
let dx = target_x - objects[id].x;
let dy = target_y - objects[id].y;
let distance = ((dx.pow(2) + dy.pow(2)) as f32).sqrt();
// normalize it to length 1 (preserving direction), then round it and
// convert to integer so the movement is restricted to the map grid
let dx = (dx as f32 / distance).round() as i32;
let dy = (dy as f32 / distance).round() as i32;
move_by(id, dx, dy, map, objects);
}
Next we’ll add a method on Object
that will tell us the distance to
another object.
/// return the distance to another object
pub fn distance_to(&self, other: &Object) -> f32 {
let dx = other.x - self.x;
let dy = other.y - self.y;
((dx.pow(2) + dy.pow(2)) as f32).sqrt()
}
All right, let’s use them to implement some basic behaviour: if the monster is close, it will attack, otherwise it will move closer.
fn ai_take_turn(monster_id: usize, tcod: &Tcod, game: &Game, objects: &mut [Object]) {
// a basic monster takes its turn. If you can see it, it can see you
let (monster_x, monster_y) = objects[monster_id].pos();
if tcod.fov.is_in_fov(monster_x, monster_y) {
if objects[monster_id].distance_to(&objects[PLAYER]) >= 2.0 {
// move towards player if far away
let (player_x, player_y) = objects[PLAYER].pos();
move_towards(monster_id, player_x, player_y, &game.map, objects);
} else if objects[PLAYER].fighter.map_or(false, |f| f.hp > 0) {
// close enough, attack! (if the player is still alive.)
let monster = &objects[monster_id];
println!(
"The attack of the {} bounces off your shiny metal armor!",
monster.name
);
}
}
}
But for any of this to have effect, we need to call it from the main loop:
// let monsters take their turn
if objects[PLAYER].alive && player_action != PlayerAction::DidntTakeTurn {
for id in 0..objects.len() {
if objects[id].ai.is_some() {
ai_take_turn(id, &tcod, &game, &mut objects);
}
}
}
When you test it now, you can see the monsters following you around and trying to attack you.
The whole code is available here.
Sword-fighting
The quest for some epic medieval combat is coming to an end! We will now write the actual functions to attack and take damage, and replace those silly placeholders with the meaty stuff. The "meaty stuff" is deliberately simple. This is so you can easily change it with your own damage system, whatever it may be.
pub fn take_damage(&mut self, damage: i32) {
// apply damage if possible
if let Some(fighter) = self.fighter.as_mut() {
if damage > 0 {
fighter.hp -= damage;
}
}
}
In the next section we’ll modify it to also handle deaths. Then there’s the method to attack another object:
pub fn attack(&mut self, target: &mut Object) {
// a simple formula for attack damage
let damage = self.fighter.map_or(0, |f| f.power) - target.fighter.map_or(0, |f| f.defense);
if damage > 0 {
// make the target take some damage
println!(
"{} attacks {} for {} hit points.",
self.name, target.name, damage
);
target.take_damage(damage);
} else {
println!(
"{} attacks {} but it has no effect!",
self.name, target.name
);
}
}
It calls the previous method in order to handle taking damage. We separated "attacks" and "damage" because you might want an event, like poison or a trap, to directly damage an object by some amount, without going through the attack damage formula.
Let’s replace the dummy attack message in ai_take_turn
with a call
to the attack
monster.
Alas, the ownership rears its head again! If you just tried the straightforward bit:
let monster = &mut objects[monster_id];
monster.attack(&mut objects[PLAYER]);
You would get another error about a double mutable borrow. While
taking two mutable pointers into the objects
list is safe when
they’re pointing at different objects, it would be a problem if they
borrowed the same one (remember, you can only have one mutable borrow
to an object at a time).
Unfortunately, Rust can’t just figure out that the monster and player are different items in the list.
However, we can let it know! There’s a method on slices called
split_at_mut
which takes an index and returns two mutable slices
split by the index. And we can use that to return a mutable borrow to
our object from both:
/// Mutably borrow two *separate* elements from the given slice.
/// Panics when the indexes are equal or out of bounds.
fn mut_two<T>(first_index: usize, second_index: usize, items: &mut [T]) -> (&mut T, &mut T) {
assert!(first_index != second_index);
let split_at_index = cmp::max(first_index, second_index);
let (first_slice, second_slice) = items.split_at_mut(split_at_index);
if first_index < second_index {
(&mut first_slice[first_index], &mut second_slice[0])
} else {
(&mut second_slice[0], &mut first_slice[second_index])
}
}
And now monster’s attack looks like this:
// close enough, attack! (if the player is still alive.)
let (monster, player) = mut_two(monster_id, PLAYER, objects);
monster.attack(player);
And do the same to the player’s dummy attack code in player_move_or_attack
:
let (player, target) = mut_two(PLAYER, target_id, objects);
player.attack(target);
That’s it, the player and the monsters can beat each other silly, but
no-one will die. We’ll take this opportunity to print the player’s HP
so you can see it plummeting to negative values as the monsters
attack you. This is how you make a simple GUI! At the end of the
render_all
function:
// show the player's stats
tcod.root.set_default_foreground(WHITE);
if let Some(fighter) = objects[PLAYER].fighter {
tcod.root.print_ex(
1,
SCREEN_HEIGHT - 2,
BackgroundFlag::None,
TextAlignment::Left,
format!("HP: {}/{} ", fighter.hp, fighter.max_hp),
);
}
We render the hitpoints only when the player has the Fighter
component. We could use objects[PLAYER].fighter.unwrap() instead of
if let here, but that would crash the game if the player ever
stopped being a fighter, which would be a shame. What if they’re under
a sanctuary spell or some such?
|
Untimely deaths
Of course, nobody can lose HP indefinitely. We’ll now code the
inevitable demise of both the monsters and the player! This is handled
by the Fighter
component. Since different objects have different
behaviors when killed, the Fighter
struct must know what function to
call when the object dies. This is so that monsters leave corpses
behind, the player loses the game, the end-level boss reveals the
stairs to the next level, etc. This on_death
callback is passed as a
parameter when creating a Fighter
instance.
// combat-related properties and methods (monster, player, NPC).
#[derive(Clone, Copy, Debug, PartialEq)]
struct Fighter {
max_hp: i32,
hp: i32,
defense: i32,
power: i32,
on_death: DeathCallback, (1)
}
1 | New on_death callback field |
Let us define the callback as well:
#[derive(Clone, Copy, Debug, PartialEq)]
enum DeathCallback {
Player,
Monster,
}
We’re adding another field to Fighter
of a new enum DeathCallback
.
It will represent the different "on death" functions we’ll have
available.
Next, we’ll add a method that will let us call the callback:
impl DeathCallback {
fn callback(self, object: &mut Object) {
use DeathCallback::*;
let callback: fn(&mut Object) = match self {
Player => player_death,
Monster => monster_death,
};
callback(object);
}
}
It checks to see which callback it represents and invokes the right
function (player_death
or monster_death
). The callback functions
take one parameter — the mutable reference to the dying object. This
is so we can change its properties on death.
And we also need to set the callback for every Fighter
instance.
Here’s the player’s one:
player.fighter = Some(Fighter {
max_hp: 30,
hp: 30,
defense: 2,
power: 5,
on_death: DeathCallback::Player, (1)
});
1 | Added on_death callback |
And this is for the monsters (in place_objects
):
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,
on_death: DeathCallback::Monster, (1)
});
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,
on_death: DeathCallback::Monster, (2)
});
troll.ai = Some(Ai::Basic);
troll
};
1 | Added on_death callback |
2 | Added on_death callback |
Before we get to writing the concrete callback implementations, lets make sure our they actually get triggered when an object dies!
We’ll do that in take_damage
rather than attack
, because an object
may die from causes other than combat, such as a trap, hunger or
poison.
Put this at the end of the take_damage
method:
// apply damage if possible
if let Some(fighter) = self.fighter.as_mut() {
// ...
}
// 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);
}
}
The first if let
check looks almost identical to the one that’s already
there for taking the hit points down. There is a difference, however.
It boils down to ownership again. The first if let
takes a mutable
reference to self.fighter
. That means, for the duration of that
block, we can’t take a mutable reference to self
, because a part of it
(fighter
) is already borrowed.
But we do need a mutable reference to pass it to the on_death
callback.
So while it may seem like we could just fold the callback code into
the first if let
, we can’t because it would result in the
simultaneous borrowing of &mut Object
and &mut Fighter
.
We do not have the same problem in the second if let
because we are
not borrowing Fighter
there. Using self.fighter
instead of
self.fighter.as_mut()
means we just copy the fighter
value, but
nothing is borrowed at that time. This would also mean that if we made
any changes to fighter
in the second if let
block, they would not
appear on the self
Object.
As mentioned before, the ownership rules are generally a good thing but sometimes they are a bit onerous.
Anyway, let’s go implement our player_death
and monster_death
callbacks!
fn player_death(player: &mut Object) {
// the game ended!
println!("You died!");
// for added effect, transform the player into a corpse!
player.char = '%';
player.color = DARK_RED;
}
fn monster_death(monster: &mut Object) {
// transform it into a nasty corpse! it doesn't block, can't be
// attacked and doesn't move
println!("{} is dead!", monster.name);
monster.char = '%';
monster.color = DARK_RED;
monster.blocks = false;
monster.fighter = None;
monster.ai = None;
monster.name = format!("remains of {}", monster.name);
}
Notice that the monster’s components were disabled, so it doesn’t run any AI functions and can no longer be attacked.
To enable these behaviours, pass the on_death
field into the
Fighter
components wherever you’ve defined them. Rust will complain
if you don’t so let the compiler guide you.
You can test play around with it now and you’ll see that the player and monsters stop moving when they die. There are some glitches we need to fix, however.
First, we only want to attack an object if it has a Fighter
component. In player_move_or_attack
, change the target check to the
following:
// try to find an attackable object there
let target_id = objects
.iter()
.position(|object| object.fighter.is_some() && object.pos() == (x, y));
is_some
is a method on Option
that will tell you whether it’s
value is Some(…)
without bothering you with the insides.
There’s also the issue that when the player walks over a corpse, it’s sometimes drawn over the player. And the same issue happens when a monster steps on a corpse.
We can fix both by sorting the list of objects by their blocks
property:
let mut to_draw: Vec<_> = objects.iter().collect();
// sort so that non-blocking objects come first
to_draw.sort_by(|o1, o2| { o1.blocks.cmp(&o2.blocks) });
// draw the objects in the list
for object in &to_draw {
if tcod.map.is_in_fov(object.x, object.y) {
object.draw(con);
}
}
Instead of going through the objects
list we clone it into a
mutable vector (render_all
is taking &[Object]
so it can’t change
the list directly — nor should it). Then we sort the vector such that
all non-blocking objects come before all the blocking ones. Since we can’t
have two blocking objects on the same tile, this will make sure that
our player and monsters won’t get overwritten by corpses.
And we can always make the logic more intricate by changing the
closure passed to sort_by
.
One more thing, since we’re only ever rendering objects that are in the field of view, let’s filter them out before the sort. That way we’ll only sort items that we actually want to draw.
let mut to_draw: Vec<_> = objects
.iter()
.filter(|o| tcod.fov.is_in_fov(o.x, o.y))
.collect();
// sort so that non-blocking objects come first
to_draw.sort_by(|o1, o2| o1.blocks.cmp(&o2.blocks));
// draw the objects in the list
for object in &to_draw {
object.draw(&mut tcod.con);
}
It’s finally ready to play, and it actually feels like a game! It’s been
a long journey since we first printed the @
character, but we’ve got
random dungeons, FOV, exploration, enemies, AI, and a true combat
system. You can now beat those pesky monsters into a pulp and walk
over them! (Ugh!) See if you can finish off all of them before they do
the same to you.
Here’s the complete code so far.
Continue to the next part.