14 - Game over
Aug 1, 2021
tdd
rust
srl
|
Prev
|
Next
|
Code
So far this game is not very balanced, isn’t it? The enemies are getting slapped around until they die. Let’s see how we can make them hit back.
A bit of housekeeping
Before tackling player damage, some housekeeping happened:
-
The
TestableGame
struct received a few quality-of-life upgrades which can be seen below (if you squint). -
The
CombatEvent
struct was converted into a trait with implementations for hit/miss/death:pub trait CombatEvent { fn log_string(&self) -> String; } impl CombatEvent for CombatEventHit { fn log_string(&self) -> String { format!("{} hits {} for {} damage!", self.attacker, self.victim, self.damage) } } // ...
-
A struct
CombatResolver
) was extracted from theGame
struct.
This one brought about some other changes with I am not necessarily happy with. It is debatable if the change was even necessary at this point. Maybe it will be a cautionary tale featured in a future episode? We will see.
Lastly, a few words on why I am OK with testing the enemy damage code for
one direction only.
Short answer, I am confident that it works for all directions because the combat log test verifies all directions, and the damage code lives in the same loop:
impl Game {
// ...
pub fn tick<T: Input>(&mut self, input: &T) {
// For all directions, if colliding with coords:
self.combat_resolver.handle_combat_with(coords, *direction);
}
CombatResolver::handle_combat_with
, for all directions.I mean, in theory, I could punk myself and run the damage code for only one direction, and the combat log code for all directions, thus making the feature tests pass and the game not work as expected.
But why would I do that? As the sole programmer I know the code well enough to not expect such a thing.
To summarize: Have enough confidence, no further tests needed.
Damaging the player
How do we test player damage in a feature test? Well, let’s say the game just terminates when the player dies. That would be a very user-visible feature.
To facilitate this, the game needs to tell the world about the player having died, with the world being the (still untested) game loop. So, let’s have a function Game::player_died
that returns true if the player, well, has died:
#[test]
fn game_over_after_player_death() {
let mut game = TestableGame::from_strings(vec![". @ E ."]);
enemy_hits_for(60, &mut game);
game.input.simulate_move(Right);
game.tick();
assert!(!game.game.player_died());
game.tick();
assert!(game.game.player_died());
}
fn enemy_hits_for(damage: u32, game: &mut TestableGame) {
game.configure_combat(|combat_engine| {
combat_engine.say_is_hit(ENEMY, PLAYER);
combat_engine.say_damage(ENEMY, damage);
});
}
This assumes that the player has 100 hitpoints. The enemy always hits for 60 damage, which means the player should be dead after the second tick, and Game::player_died
should return true
at this point. (Look, we don’t even have to render the game here. Funny, isn’t it?)
After stubbing out Game::player_died
, this fails as expected.
To pass the test, we would like to do something like this:
impl Game {
// ...
pub fn tick<T: Input>(&mut self, input: &T) {
for direction in MoveDirection::iter() {
if input.wants_to_move(*direction) {
let result = self.dungeon_ref.borrow_mut().move_player(*direction);
if let Some((coords, Enemy)) = result {
self.player_died = self.combat_resolver.handle_combat_with(
coords, *direction);
}
}
}
}
pub fn player_died(&self) -> bool {
self.player_died
}
}
This requires a bit of plumbing inside the Dungeon
struct, and CombatResolver::handle_combat_with
gets a return value and a new deeply nested return statement:
impl CombatResolver {
pub fn handle_combat_with(&mut self, coords: DungeonCoords,
direction: MoveDirection) -> bool {
// ...
if self.combat_engine.is_hit(coords, player_pos) {
let damage = self.combat_engine.roll_damage(coords);
self.add_combat_event(CombatEventHit::new(Enemy, Player, damage));
let remaining_hp = self.dungeon_ref.borrow_mut().damage_player(damage);
if remaining_hp <= 0 {
self.add_combat_event(CombatEventDeath::new(Player));
return true;
}
// ...
}
// ...
}
And finally, in a rare trip to main.rs
, we can use the new API like this:
while !input.quit_game() && !game.player_died() {
game.render(&mut renderer);
renderer.flush(&mut term);
input.on_key(term.read_key()?);
game.tick(&input);
term.clear_last_lines(GAME_HEIGHT - 1)?;
}
if game.player_died() {
game.render(&mut renderer);
renderer.flush(&mut term);
println!("\nYou died. Thanks for playing!");
}
All very raw and subject to future cleanup, but here it is.
But, how can we win the game? Easy. Just kill all the enemies. Which we will tackle after a short intermission featuring the “user interface”.