Part 6 - Doing (and taking) some damage
The last part of this tutorial set us up for combat, so now it’s time to actually implement it.
In order to make “killable” Entities, rather than attaching hit points
to each Entity we create, we’ll create a component, called
Fighter
, which will hold information related to combat, like HP, max
HP, attack, and defense. If an Entity can fight, it will have this
component attached to it, and if not, it won’t. This way of doing things
is called composition, and it’s an alternative to your typical
inheritance-based programming model.
Create a new Python package (a folder with an empty __init__.py
file), called components
. In there, put a new file called
fighter.py
, and put the following code in it:
class Fighter:
def __init__(self, hp, defense, power):
self.max_hp = hp
self.hp = hp
self.defense = defense
self.power = power
These variables should look familiar to anyone who’s played an RPG before. HP represents the entity’s health, defense blocks damage, and power is the entity’s attack strength. Perhaps the game you have in mind has a more complex combat model, but we’ll keep it simple here.
Another component we’ll need is one to define the enemy AI. Some entities (enemies) will have AI, whereas others (player, items) will not. We’ll set up our game loop to allow any entity with an AI component, regardless of what it is, to take a turn, and all others won’t get to.
Create a file in components
called ai.py
, and put the following
class in it:
class BasicMonster:
def take_turn(self):
print('The ' + self.owner.name + ' wonders when it will get to move.')
We’ve defined a basic method called take_turn
, which we’ll call in our
game loop in a minute. It’s just a placeholder for now, but by the end
of this chapter, the take_turn
function will actually move the entity
around.
With our classes in place, we’ll turn our attention to the Entity
class once more. We need to pass the components through the constructor,
like we do for everything else. Modify the __init__
function in
Entity
to look like this:
class Entity:
- def __init__(self, x, y, char, color, name, blocks=False):
+ def __init__(self, x, y, char, color, name, blocks=False, fighter=None, ai=None):
self.x = x
self.y = y
self.char = char
self.color = color
self.name = name
self.blocks = blocks
+ self.fighter = fighter
+ self.ai = ai
+
+ if self.fighter:
+ self.fighter.owner = self
+
+ if self.ai:
+ self.ai.owner = self
class Entity: def __init__(self, x, y, char, color, name, blocks=False): def __init__(self, x, y, char, color, name, blocks=False, fighter=None, ai=None): self.x = x self.y = y self.char = char self.color = color self.name = name self.blocks = blocks self.fighter = fighter self.ai = ai if self.fighter: self.fighter.owner = self if self.ai: self.ai.owner = self
So the fighter
and ai
components are optional, so entities that
don’t need them won’t need to do anything.
Why do we need to set the owner of the component to self? There will be
a few instances where we’ll want to access the Entity from within the
component. In our previous bit of code for the BasicMonster
, we gained
access to the entity’s “name” simply by referencing the “owner”. We just
have to be sure we set the owner upon initializing the entity.
Now we’ll need to add our new components to all the entities we’ve
created so far. Let’s start with the easiest one: the player. The player
doesn’t actually need AI (because we’re controlling the player object
directly), but it does need the Fighter
component.
First, import the Fighter
component into engine.py
:
import tcod as libtcod
+from components.fighter import Fighter
from entity import Entity, get_blocking_entities_at_location
import tcod as libtcod
from components.fighter import Fighter
from entity import Entity, get_blocking_entities_at_location
Then, create the component and add it to the player Entity.
+ fighter_component = Fighter(hp=30, defense=2, power=5)
- player = Entity(0, 0, '@', libtcod.white, 'Player', blocks=True)
+ player = Entity(0, 0, '@', libtcod.white, 'Player', blocks=True, fighter=fighter_component)
entities = [player]
...
fighter_component = Fighter(hp=30, defense=2, power=5) player = Entity(0, 0, '@', libtcod.white, 'Player', blocks=True) player = Entity(0, 0, '@', libtcod.white, 'Player', blocks=True, fighter=fighter_component) entities = [player] ...
And now for our monsters. We’ll need both the Fighter and BasicMonster components for them.
if randint(0, 100) < 80:
+ fighter_component = Fighter(hp=10, defense=0, power=3)
+ ai_component = BasicMonster()
- monster = Entity(x, y, 'o', libtcod.desaturated_green, 'Orc', blocks=True)
+ monster = Entity(x, y, 'o', libtcod.desaturated_green, 'Orc', blocks=True,
+ fighter=fighter_component, ai=ai_component)
else:
+ fighter_component = Fighter(hp=16, defense=1, power=4)
+ ai_component = BasicMonster()
- monster = Entity(x, y, 'T', libtcod.darker_green, 'Troll', blocks=True)
+ monster = Entity(x, y, 'T', libtcod.darker_green, 'Troll', blocks=True, fighter=fighter_component,
+ ai=ai_component)
if randint(0, 100) < 80: fighter_component = Fighter(hp=10, defense=0, power=3) ai_component = BasicMonster() monster = Entity(x, y, 'o', libtcod.desaturated_green, 'Orc', blocks=True) monster = Entity(x, y, 'o', libtcod.desaturated_green, 'Orc', blocks=True, fighter=fighter_component, ai=ai_component) else: fighter_component = Fighter(hp=16, defense=1, power=4) ai_component = BasicMonster() monster = Entity(x, y, 'T', libtcod.darker_green, 'Troll', blocks=True) monster = Entity(x, y, 'T', libtcod.darker_green, 'Troll', blocks=True, fighter=fighter_component, ai=ai_component)
Remember to import the needed classes at the top.
import tcod as libtcod
from random import randint
+from components.ai import BasicMonster
+from components.fighter import Fighter
from entity import Entity
from map_objects.rectangle import Rect
from map_objects.tile import Tile
import tcod as libtcod
from random import randint
from components.ai import BasicMonster
from components.fighter import Fighter
from entity import Entity
from map_objects.rectangle import Rect
from map_objects.tile import Tile
Now we can modify our monster’s turn loop to use the take_turn
function.
...
if game_state == GameStates.ENEMY_TURN:
for entity in entities:
- if entity != player:
+ if entity.ai:
- print('The ' + entity.name + ' ponders the meaning of its existence.')
+ entity.ai.take_turn()
game_state = GameStates.PLAYERS_TURN
...
... if game_state == GameStates.ENEMY_TURN: for entity in entities: if entity != player: if entity.ai: print('The ' + entity.name + ' ponders the meaning of its existence.') entity.ai.take_turn() game_state = GameStates.PLAYERS_TURN ...
Not a whole lot has changed yet (we’re still printing something instead of the monsters taking a real turn), but we’re getting there. Notice that rather than checking if the entity is not the player, we’re checking if the entity has an AI component. The player doesn’t have an AI component, so the loop will skip the player, but more importantly, any items we implement later on won’t get a “turn” either.
Now for our actual AI implementation. Our AI will be very simple (stupidly so, really). If the enemy can “see” the player, it will move towards the player, and if it is next to the player, it will attack. We won’t implement enemy FOV in this tutorial; instead, we’ll just assume that if you can see an enemy, it can see you too.
Let’s put a basic movement function in place. Put the following code in
the Entity
class.
def move(self, dx, dy):
...
+ def move_towards(self, target_x, target_y, game_map, entities):
+ dx = target_x - self.x
+ dy = target_y - self.y
+ distance = math.sqrt(dx ** 2 + dy ** 2)
+
+ dx = int(round(dx / distance))
+ dy = int(round(dy / distance))
+
+ if not (game_map.is_blocked(self.x + dx, self.y + dy) or
+ get_blocking_entities_at_location(entities, self.x + dx, self.y + dy)):
+ self.move(dx, dy)
def move(self, dx, dy):
...
def move_towards(self, target_x, target_y, game_map, entities):
dx = target_x - self.x
dy = target_y - self.y
distance = math.sqrt(dx ** 2 + dy ** 2)
dx = int(round(dx / distance))
dy = int(round(dy / distance))
if not (game_map.is_blocked(self.x + dx, self.y + dy) or
get_blocking_entities_at_location(entities, self.x + dx, self.y + dy)):
self.move(dx, dy)
We’ll also need a function to get the distance between the Entity and its target.
def move_towards(self, target_x, target_y, game_map, entities):
...
+ def distance_to(self, other):
+ dx = other.x - self.x
+ dy = other.y - self.y
+ return math.sqrt(dx ** 2 + dy ** 2)
def move_towards(self, target_x, target_y, game_map, entities):
...
def distance_to(self, other):
dx = other.x - self.x
dy = other.y - self.y
return math.sqrt(dx ** 2 + dy ** 2)
Both of these functions use the math
module, so we’ll need to import
that.
+import math
class Entity:
...
import math
class Entity:
...
Now let’s replace our placeholder take_turn
function with one that
will actually move the Entity.
import tcod as libtcod
class BasicMonster:
- def take_turn(self):
+ def take_turn(self, target, fov_map, game_map, entities):
- print('The ' + self.owner.name + ' wonders when it will get to move.')
+ monster = self.owner
+ if libtcod.map_is_in_fov(fov_map, monster.x, monster.y):
+
+ if monster.distance_to(target) >= 2:
+ monster.move_towards(target.x, target.y, game_map, entities)
+
+ elif target.fighter.hp > 0:
+ print('The {0} insults you! Your ego is damaged!'.format(monster.name))
import tcod as libtcod class BasicMonster: def take_turn(self): def take_turn(self, target, fov_map, game_map, entities): print('The ' + self.owner.name + ' wonders when it will get to move.') monster = self.owner if libtcod.map_is_in_fov(fov_map, monster.x, monster.y): if monster.distance_to(target) >= 2: monster.move_towards(target.x, target.y, game_map, entities) elif target.fighter.hp > 0: print('The {0} insults you! Your ego is damaged!'.format(monster.name))
We’ll also need to update the call to take_turn
in engine.py
- entity.ai.take_turn()
+ entity.ai.take_turn(player, fov_map, game_map, entities)
entity.ai.take_turn() entity.ai.take_turn(player, fov_map, game_map, entities)
Now our enemies will give chase, and, if they catch up, hurl insults at our poor player!
If you run the project, you may notice something strange about our mean spirited monsters: They can insult you from a diagonal position, but the player and the monsters can only move in the cardinal directions (north, east, south, west). If the enemies were actually attacking us right now, they’d have an unfair advantage. While this could make for interesting gameplay, we’ll fix that here to allow for 8 directional attacking and movement for all Entities.
For the player, that’s easy enough; we just need to update handle_keys
to allow us to move diagonally. Modify the movement part of that
function like so:
def handle_keys(key):
+ key_char = chr(key.c)
- if key.vk == libtcod.KEY_UP:
+ if key.vk == libtcod.KEY_UP or key_char == 'k':
return {'move': (0, -1)}
- elif key.vk == libtcod.KEY_DOWN:
+ elif key.vk == libtcod.KEY_DOWN or key_char == 'j':
return {'move': (0, 1)}
- elif key.vk == libtcod.KEY_LEFT:
+ elif key.vk == libtcod.KEY_LEFT or key_char == 'h':
return {'move': (-1, 0)}
- elif key.vk == libtcod.KEY_RIGHT:
+ elif key.vk == libtcod.KEY_RIGHT or key_char == 'l':
return {'move': (1, 0)}
+ elif key_char == 'y':
+ return {'move': (-1, -1)}
+ elif key_char == 'u':
+ return {'move': (1, -1)}
+ elif key_char == 'b':
+ return {'move': (-1, 1)}
+ elif key_char == 'n':
+ return {'move': (1, 1)}
...
def handle_keys(key): key_char = chr(key.c) if key.vk == libtcod.KEY_UP or key_char == 'k': return {'move': (0, -1)} elif key.vk == libtcod.KEY_DOWN or key_char == 'j': return {'move': (0, 1)} elif key.vk == libtcod.KEY_LEFT or key_char == 'h': return {'move': (-1, 0)} elif key.vk == libtcod.KEY_RIGHT or key_char == 'l': return {'move': (1, 0)} elif key_char == 'y': return {'move': (-1, -1)} elif key_char == 'u': return {'move': (1, -1)} elif key_char == 'b': return {'move': (-1, 1)} elif key_char == 'n': return {'move': (1, 1)} ...
The first line is just getting the ‘character’ that we pressed on the keyboard. This will be handy in other spots as well, when we check for inventory and pickup commands.
For diagonal movement, we’ve implemented the “vim keys” for movement, while also retaining the arrow keys for cardinal directions. Vim keys allow you to move diagonally without the help of a numpad. A lot of older roguelikes do 8 directions through the numpad, but personally, I play all my roguelikes on a laptop, which doesn’t have one, so the Vim keys are useful.
Getting the enemies to move in eight directions is going to be a bit more complicated. For that, we’ll want to use a pathfinding algorithm known as A-star. I’m simply going to be copying the code from the Roguebasin extra for our purposes. I won’t go into detail explaining how this works, but if you want to know more about the details of the algorithm, click here.
def move_towards(self, target_x, target_y, game_map, entities):
...
+ def move_astar(self, target, entities, game_map):
+ # Create a FOV map that has the dimensions of the map
+ fov = libtcod.map_new(game_map.width, game_map.height)
+
+ # Scan the current map each turn and set all the walls as unwalkable
+ for y1 in range(game_map.height):
+ for x1 in range(game_map.width):
+ libtcod.map_set_properties(fov, x1, y1, not game_map.tiles[x1][y1].block_sight,
+ not game_map.tiles[x1][y1].blocked)
+
+ # Scan all the objects to see if there are objects that must be navigated around
+ # Check also that the object isn't self or the target (so that the start and the end points are free)
+ # The AI class handles the situation if self is next to the target so it will not use this A* function anyway
+ for entity in entities:
+ if entity.blocks and entity != self and entity != target:
+ # Set the tile as a wall so it must be navigated around
+ libtcod.map_set_properties(fov, entity.x, entity.y, True, False)
+
+ # Allocate a A* path
+ # The 1.41 is the normal diagonal cost of moving, it can be set as 0.0 if diagonal moves are prohibited
+ my_path = libtcod.path_new_using_map(fov, 1.41)
+
+ # Compute the path between self's coordinates and the target's coordinates
+ libtcod.path_compute(my_path, self.x, self.y, target.x, target.y)
+
+ # Check if the path exists, and in this case, also the path is shorter than 25 tiles
+ # The path size matters if you want the monster to use alternative longer paths (for example through other rooms) if for example the player is in a corridor
+ # It makes sense to keep path size relatively low to keep the monsters from running around the map if there's an alternative path really far away
+ if not libtcod.path_is_empty(my_path) and libtcod.path_size(my_path) < 25:
+ # Find the next coordinates in the computed full path
+ x, y = libtcod.path_walk(my_path, True)
+ if x or y:
+ # Set self's coordinates to the next path tile
+ self.x = x
+ self.y = y
+ else:
+ # Keep the old move function as a backup so that if there are no paths (for example another monster blocks a corridor)
+ # it will still try to move towards the player (closer to the corridor opening)
+ self.move_towards(target.x, target.y, game_map, entities)
+
+ # Delete the path to free memory
+ libtcod.path_delete(my_path)
def move_towards(self, target_x, target_y, game_map, entities):
...
def move_astar(self, target, entities, game_map):
# Create a FOV map that has the dimensions of the map
fov = libtcod.map_new(game_map.width, game_map.height)
# Scan the current map each turn and set all the walls as unwalkable
for y1 in range(game_map.height):
for x1 in range(game_map.width):
libtcod.map_set_properties(fov, x1, y1, not game_map.tiles[x1][y1].block_sight,
not game_map.tiles[x1][y1].blocked)
# Scan all the objects to see if there are objects that must be navigated around
# Check also that the object isn't self or the target (so that the start and the end points are free)
# The AI class handles the situation if self is next to the target so it will not use this A* function anyway
for entity in entities:
if entity.blocks and entity != self and entity != target:
# Set the tile as a wall so it must be navigated around
libtcod.map_set_properties(fov, entity.x, entity.y, True, False)
# Allocate a A* path
# The 1.41 is the normal diagonal cost of moving, it can be set as 0.0 if diagonal moves are prohibited
my_path = libtcod.path_new_using_map(fov, 1.41)
# Compute the path between self's coordinates and the target's coordinates
libtcod.path_compute(my_path, self.x, self.y, target.x, target.y)
# Check if the path exists, and in this case, also the path is shorter than 25 tiles
# The path size matters if you want the monster to use alternative longer paths (for example through other rooms) if for example the player is in a corridor
# It makes sense to keep path size relatively low to keep the monsters from running around the map if there's an alternative path really far away
if not libtcod.path_is_empty(my_path) and libtcod.path_size(my_path) < 25:
# Find the next coordinates in the computed full path
x, y = libtcod.path_walk(my_path, True)
if x or y:
# Set self's coordinates to the next path tile
self.x = x
self.y = y
else:
# Keep the old move function as a backup so that if there are no paths (for example another monster blocks a corridor)
# it will still try to move towards the player (closer to the corridor opening)
self.move_towards(target.x, target.y, game_map, entities)
# Delete the path to free memory
libtcod.path_delete(my_path)
For this to work, we’ll need to import libtcod
into entity.py
:
+import tcod as libtcod
import math
...
import tcod as libtcod
import math
...
Note that if for whatever reason the algorithm doesn’t find a path, it will revert back to our previous movement function, so we still need that.
Modify the take_turn
function in BasicMonster
to take advantage of
this new function.
...
if monster.distance_to(target) >= 2:
+ monster.move_astar(target, entities, game_map)
- monster.move_towards(target.x, target.y, game_map, entities)
...
... if monster.distance_to(target) >= 2: monster.move_astar(target, entities, game_map) monster.move_towards(target.x, target.y, game_map, entities) ...
Now both the player and enemies can move in diagonals. With that taken
care of, it’s time to implement an actual combat system. Let’s start by
adding a method to Fighter
that allows the entity to take damage.
class Fighter:
def __init__(self, hp, defense, power):
...
+ def take_damage(self, amount):
+ self.hp -= amount
class Fighter:
def __init__(self, hp, defense, power):
...
def take_damage(self, amount):
self.hp -= amount
Simple enough. Now for the attack function (also in Fighter
):
...
+ def attack(self, target):
+ damage = self.power - target.fighter.defense
+
+ if damage > 0:
+ target.fighter.take_damage(damage)
+ print('{0} attacks {1} for {2} hit points.'.format(self.owner.name.capitalize(), target.name, str(damage)))
+ else:
+ print('{0} attacks {1} but does no damage.'.format(self.owner.name.capitalize(), target.name))
...
def attack(self, target):
damage = self.power - target.fighter.defense
if damage > 0:
target.fighter.take_damage(damage)
print('{0} attacks {1} for {2} hit points.'.format(self.owner.name.capitalize(), target.name, str(damage)))
else:
print('{0} attacks {1} but does no damage.'.format(self.owner.name.capitalize(), target.name))
There’s nothing too complex about this system. We’re taking the attacker’s power and subtracting the defender’s defense, and getting our damage dealt. If the damage is above zero, then the target takes damage.
We can finally replace our placeholders from earlier! Modify the
player’s placeholder in engine.py
:
if target:
- print('You kick the ' + target.name + ' in the shins, much to its annoyance!')
+ player.fighter.attack(target)
if target: print('You kick the ' + target.name + ' in the shins, much to its annoyance!') player.fighter.attack(target)
… And for the enemy placeholder in BasicMonster
...
elif target.fighter.hp > 0:
- print('The {0} insults you! Your ego is damaged!'.format(monster.name))
+ monster.fighter.attack(target)
... elif target.fighter.hp > 0: print('The {0} insults you! Your ego is damaged!'.format(monster.name)) monster.fighter.attack(target)
Now we can attack enemies, and they can attack us!
As exciting as this all is, we have to take a step back for a moment and
think about a design question. Right now, we’re printing our messages to
the console, but in the next chapter we’ll move that to a more formal
message log. Also, later in this chapter, we need to alter the game
state when the player is killed in action. Do functions like attack
and take_damage
really need to receive the message log or game state
as arguments? And should they be directly manipulating those things in
the first place?
There’s a lot of different ways to handle this. For this tutorial, we’ll
implement a results
list for functions like this, which will be
returned to the engine.py
file, and be handled there. We’re already
doing something similar in handle_keys
; that function just returns the
results of the key press, it doesn’t actually move the player.
Let’s modify the take_damage
and attack
functions to return an array
of results, rather than print anything.
def take_damage(self, amount):
+ results = []
self.hp -= amount
+ if self.hp <= 0:
+ results.append({'dead': self.owner})
+
+ return results
def attack(self, target):
+ results = []
damage = self.power - target.fighter.defense
if damage > 0:
- target.fighter.take_damage(damage)
- print('{0} attacks {1} for {2} hit points.'.format(self.owner.name.capitalize(), target.name, str(damage)))
+ results.append({'message': '{0} attacks {1} for {2} hit points.'.format(
+ self.owner.name.capitalize(), target.name, str(damage))})
+ results.extend(target.fighter.take_damage(damage))
else:
- print('{0} attacks {1} but does no damage.'.format(self.owner.name.capitalize(), target.name))
+ results.append({'message': '{0} attacks {1} but does no damage.'.format(
+ self.owner.name.capitalize(), target.name)})
+
+ return results
def take_damage(self, amount): results = [] self.hp -= amount if self.hp <= 0: results.append({'dead': self.owner}) return results def attack(self, target): results = [] damage = self.power - target.fighter.defense if damage > 0: target.fighter.take_damage(damage) print('{0} attacks {1} for {2} hit points.'.format(self.owner.name.capitalize(), target.name, str(damage))) results.append({'message': '{0} attacks {1} for {2} hit points.'.format( self.owner.name.capitalize(), target.name, str(damage))}) results.extend(target.fighter.take_damage(damage)) else: print('{0} attacks {1} but does no damage.'.format(self.owner.name.capitalize(), target.name)) results.append({'message': '{0} attacks {1} but does no damage.'.format( self.owner.name.capitalize(), target.name)}) return results
Let’s break it down a little. In take_damage
, we add a dictionary to
results
if the entity happens to die after taking damage. The results
list is returned regardless (it may be empty).
In attack
, we’re again setting a list called results
, and we add our
message to it regardless of damage was taken or not. Notice that in the
if
block, we’re using extend
to add the results of take_damage
to
our current results
list.
The extend
function is similar to append
, but it keeps our list
flat, so we don’t get something like [{'message': 'something'}, [{'message': 'something else'}]]
. Instead, we would get: [{'message': 'something'}, {'message': 'something else'}]
. That will make looping
through our results much simpler.
Let’s extend this logic to the take_turn
function in BasicMonster
.
class BasicMonster:
def take_turn(self, target, fov_map, game_map, entities):
+ results = []
monster = self.owner
if libtcod.map_is_in_fov(fov_map, monster.x, monster.y):
if monster.distance_to(target) >= 2:
monster.move_astar(target, entities, game_map)
elif target.fighter.hp > 0:
- monster.fighter.attack(target)
+ attack_results = monster.fighter.attack(target)
+ results.extend(attack_results)
+
+ return results
class BasicMonster: def take_turn(self, target, fov_map, game_map, entities): results = [] monster = self.owner if libtcod.map_is_in_fov(fov_map, monster.x, monster.y): if monster.distance_to(target) >= 2: monster.move_astar(target, entities, game_map) elif target.fighter.hp > 0: monster.fighter.attack(target) attack_results = monster.fighter.attack(target) results.extend(attack_results) return results
So what do we actually do with this results
list? Lets modify
engine.py
to react to the results of our attacks.
...
fullscreen = action.get('fullscreen')
+ player_turn_results = []
if move and game_state == GameStates.PLAYERS_TURN:
dx, dy = move
destination_x = player.x + dx
destination_y = player.y + dy
if not game_map.is_blocked(destination_x, destination_y):
target = get_blocking_entities_at_location(entities, destination_x, destination_y)
if target:
- player.fighter.attack(target)
+ attack_results = player.fighter.attack(target)
+ player_turn_results.extend(attack_results)
else:
player.move(dx, dy)
fov_recompute = True
game_state = GameStates.ENEMY_TURN
if exit:
return True
if fullscreen:
libtcod.console_set_fullscreen(not libtcod.console_is_fullscreen())
+ for player_turn_result in player_turn_results:
+ message = player_turn_result.get('message')
+ dead_entity = player_turn_result.get('dead')
+
+ if message:
+ print(message)
+
+ if dead_entity:
+ pass # We'll do something here momentarily
if game_state == GameStates.ENEMY_TURN:
for entity in entities:
if entity.ai:
- entity.ai.take_turn(player, fov_map, game_map, entities)
+ enemy_turn_results = entity.ai.take_turn(player, fov_map, game_map, entities)
+
+ for enemy_turn_result in enemy_turn_results:
+ message = enemy_turn_result.get('message')
+ dead_entity = enemy_turn_result.get('dead')
+
+ if message:
+ print(message)
+
+ if dead_entity:
+ pass
+
+ else:
game_state = GameStates.PLAYERS_TURN
... fullscreen = action.get('fullscreen') player_turn_results = [] if move and game_state == GameStates.PLAYERS_TURN: dx, dy = move destination_x = player.x + dx destination_y = player.y + dy if not game_map.is_blocked(destination_x, destination_y): target = get_blocking_entities_at_location(entities, destination_x, destination_y) if target: player.fighter.attack(target) attack_results = player.fighter.attack(target) player_turn_results.extend(attack_results) else: player.move(dx, dy) fov_recompute = True game_state = GameStates.ENEMY_TURN if exit: return True if fullscreen: libtcod.console_set_fullscreen(not libtcod.console_is_fullscreen()) for player_turn_result in player_turn_results: message = player_turn_result.get('message') dead_entity = player_turn_result.get('dead') if message: print(message) if dead_entity: pass # We'll do something here momentarily if game_state == GameStates.ENEMY_TURN: for entity in entities: if entity.ai: entity.ai.take_turn(player, fov_map, game_map, entities) enemy_turn_results = entity.ai.take_turn(player, fov_map, game_map, entities) for enemy_turn_result in enemy_turn_results: message = enemy_turn_result.get('message') dead_entity = enemy_turn_result.get('dead') if message: print(message) if dead_entity: pass else: game_state = GameStates.PLAYERS_TURN
* Note: There’s that for-else statement again. There’s no break
statement yet, so the ’else’ will always happen, but we’ll add it in
just a minute.
Not that much has changed yet, but now we’ve set ourselves up to handle
the death of the player and the other entities. Let’s implement that
now. Create a new python file called death_functions.py
and put the
following two functions in it:
import tcod as libtcod
from game_states import GameStates
def kill_player(player):
player.char = '%'
player.color = libtcod.dark_red
return 'You died!', GameStates.PLAYER_DEAD
def kill_monster(monster):
death_message = '{0} is dead!'.format(monster.name.capitalize())
monster.char = '%'
monster.color = libtcod.dark_red
monster.blocks = False
monster.fighter = None
monster.ai = None
monster.name = 'remains of ' + monster.name
return death_message
These two functions will handle the death of the player and monsters. They’re different because obviously the death of a monster isn’t that big a deal (we’ll be killing quite a few of them), but the death of the player is a very big deal (this is a roguelike after all!).
Modify engine.py
to use these two functions. Replace the pass
section like this:
...
if dead_entity:
- pass
+ if dead_entity == player:
+ message, game_state = kill_player(dead_entity)
+ else:
+ message = kill_monster(dead_entity)
+
+ print(message)
if game_state == GameStates.ENEMY_TURN:
for entity in entities:
if entity.ai:
enemy_turn_results = entity.ai.take_turn(player, fov_map, game_map, entities)
for enemy_turn_result in enemy_turn_results:
message = enemy_turn_result.get('message')
dead_entity = enemy_turn_result.get('dead')
if message:
print(message)
if dead_entity:
- pass
+ if dead_entity == player:
+ message, game_state = kill_player(dead_entity)
+ else:
+ message = kill_monster(dead_entity)
+
+ print(message)
+
+ if game_state == GameStates.PLAYER_DEAD:
+ break
+
+ if game_state == GameStates.PLAYER_DEAD:
+ break
else:
game_state = GameStates.PLAYERS_TURN
... if dead_entity: pass if dead_entity == player: message, game_state = kill_player(dead_entity) else: message = kill_monster(dead_entity) print(message) if game_state == GameStates.ENEMY_TURN: for entity in entities: if entity.ai: enemy_turn_results = entity.ai.take_turn(player, fov_map, game_map, entities) for enemy_turn_result in enemy_turn_results: message = enemy_turn_result.get('message') dead_entity = enemy_turn_result.get('dead') if message: print(message) if dead_entity: pass if dead_entity == player: message, game_state = kill_player(dead_entity) else: message = kill_monster(dead_entity) print(message) if game_state == GameStates.PLAYER_DEAD: break if game_state == GameStates.PLAYER_DEAD: break else: game_state = GameStates.PLAYERS_TURN
*Note: There’s the break statements that will skip over the ’else’ in our ‘for-else’. Why do this? Because if the player is dead, we don’t want to set the game state back to the player’s turn when all the enemies are done moving. That, and there’s no reason to continue with the loop; the game is over.
Remember to import the killing functions at the top of engine.py
:
...
from components.fighter import Fighter
+from death_functions import kill_monster, kill_player
from entity import Entity, get_blocking_entities_at_location
...
...
from components.fighter import Fighter
from death_functions import kill_monster, kill_player
from entity import Entity, get_blocking_entities_at_location
...
Also, we need to add the PLAYER_DEAD
value to GameStates
:
class GameStates(Enum):
PLAYERS_TURN = 1
ENEMY_TURN = 2
+ PLAYER_DEAD = 3
class GameStates(Enum):
PLAYERS_TURN = 1
ENEMY_TURN = 2
PLAYER_DEAD = 3
Run the project now. Entities will now drop dead when hitting 0 HP, including the player! When the player dies, you won’t be able to move, but you can still exit the game. At long last, we have a real combat system in place!
It’s been a long chapter already, but let’s clean things up just a
little bit. Right now, we’re clueless as to how much HP the player has
remaining before death. Rather than having the user keep track of the
math in their head, we can add a little health bar by putting the
following code at the end of render_all
, right before the blit
statement (note that the player needs to be passed to render_all
now).
-def render_all(con, entities, game_map, fov_map, fov_recompute, screen_width, screen_height, colors):
+def render_all(con, entities, player, game_map, fov_map, fov_recompute, screen_width, screen_height, colors):
...
for entity in entities:
draw_entity(con, entity, fov_map)
+ libtcod.console_set_default_foreground(con, libtcod.white)
+ libtcod.console_print_ex(con, 1, screen_height - 2, libtcod.BKGND_NONE, libtcod.LEFT,
+ 'HP: {0:02}/{1:02}'.format(player.fighter.hp, player.fighter.max_hp))
libtcod.console_blit(con, 0, 0, screen_width, screen_height, 0, 0, 0)
def render_all(con, entities, game_map, fov_map, fov_recompute, screen_width, screen_height, colors): def render_all(con, entities, player, game_map, fov_map, fov_recompute, screen_width, screen_height, colors): ... for entity in entities: draw_entity(con, entity, fov_map) libtcod.console_set_default_foreground(con, libtcod.white) libtcod.console_print_ex(con, 1, screen_height - 2, libtcod.BKGND_NONE, libtcod.LEFT, 'HP: {0:02}/{1:02}'.format(player.fighter.hp, player.fighter.max_hp)) libtcod.console_blit(con, 0, 0, screen_width, screen_height, 0, 0, 0)
Update the call to render_all
in
engine.py
:
-render_all(con, entities, game_map, fov_map, fov_recompute, screen_width, screen_height, colors)
+render_all(con, entities, player, game_map, fov_map, fov_recompute, screen_width, screen_height, colors)
render_all(con, entities, game_map, fov_map, fov_recompute, screen_width, screen_height, colors) render_all(con, entities, player, game_map, fov_map, fov_recompute, screen_width, screen_height, colors)
One thing you probably noticed by now is that the enemy corpses will “cover up” the player if we move onto them. This obviously isn’t desired; acting entities should always appear above corpses, items, and other things in the dungeon. To solve this, let’s add an Enum to the Entities, that describes the render order in which they should be drawn. Lower priority items will be drawn first, to ensure they never appear above the Entities.
Add the following to render_functions.py
:
import tcod as libtcod
+from enum import Enum
+
+
+class RenderOrder(Enum):
+ CORPSE = 1
+ ITEM = 2
+ ACTOR = 3
def render_all(con, entities, player, game_map, fov_map, fov_recompute, screen_width, screen_height, colors):
...
import tcod as libtcod
from enum import Enum
class RenderOrder(Enum):
CORPSE = 1
ITEM = 2
ACTOR = 3
def render_all(con, entities, player, game_map, fov_map, fov_recompute, screen_width, screen_height, colors):
...
Now modify the __init__
function in Entity
to take this into
account.
import tcod as libtcod
import math
+from render_functions import RenderOrder
class Entity:
"""
A generic object to represent players, enemies, items, etc.
"""
- def __init__(self, x, y, char, color, name, blocks=False, fighter=None, ai=None):
+ def __init__(self, x, y, char, color, name, blocks=False, render_order=RenderOrder.CORPSE, fighter=None, ai=None):
self.x = x
self.y = y
self.char = char
self.color = color
self.name = name
self.blocks = blocks
+ self.render_order = render_order
self.fighter = fighter
...
import tcod as libtcod import math from render_functions import RenderOrder class Entity: """ A generic object to represent players, enemies, items, etc. """ def __init__(self, x, y, char, color, name, blocks=False, render_order=RenderOrder.CORPSE, fighter=None, ai=None): self.x = x self.y = y self.char = char self.color = color self.name = name self.blocks = blocks self.render_order = render_order self.fighter = fighter ...
Now modify our Entity initializations, starting with
engine.py
:
-player = Entity(0, 0, '@', libtcod.white, 'Player', blocks=True, fighter=fighter_component)
+player = Entity(0, 0, '@', libtcod.white, 'Player', blocks=True, render_order=RenderOrder.ACTOR, fighter=fighter_component)
player = Entity(0, 0, '@', libtcod.white, 'Player', blocks=True, render_order=RenderOrder.ACTOR, fighter=fighter_component)
… And don’t leave out the import:
-from render_functions import clear_all, render_all
+from render_functions import clear_all, render_all, RenderOrder
from render_functions import clear_all, render_all, RenderOrder
Now for the monsters, in game_map.py
:
if randint(0, 100) < 80:
fighter_component = Fighter(hp=10, defense=0, power=3)
ai_component = BasicMonster()
- monster = Entity(x, y, 'o', libtcod.desaturated_green, 'Orc', blocks=True,
- fighter=fighter_component, ai=ai_component)
+ monster = Entity(x, y, 'o', libtcod.desaturated_green, 'Orc', blocks=True,
+ render_order=RenderOrder.ACTOR, fighter=fighter_component, ai=ai_component)
else:
fighter_component = Fighter(hp=16, defense=1, power=4)
ai_component = BasicMonster()
- monster = Entity(x, y, 'T', libtcod.darker_green, 'Troll', blocks=True, fighter=fighter_component,
- ai=ai_component)
+ monster = Entity(x, y, 'T', libtcod.darker_green, 'Troll', blocks=True, fighter=fighter_component,
+ render_order=RenderOrder.ACTOR, ai=ai_component)
if randint(0, 100) < 80: fighter_component = Fighter(hp=10, defense=0, power=3) ai_component = BasicMonster() monster = Entity(x, y, 'o', libtcod.desaturated_green, 'Orc', blocks=True, fighter=fighter_component, ai=ai_component) monster = Entity(x, y, 'o', libtcod.desaturated_green, 'Orc', blocks=True, render_order=RenderOrder.ACTOR, fighter=fighter_component, ai=ai_component) else: fighter_component = Fighter(hp=16, defense=1, power=4) ai_component = BasicMonster() monster = Entity(x, y, 'T', libtcod.darker_green, 'Troll', blocks=True, fighter=fighter_component, ai=ai_component) monster = Entity(x, y, 'T', libtcod.darker_green, 'Troll', blocks=True, fighter=fighter_component, render_order=RenderOrder.ACTOR, ai=ai_component)
… And the import:
from render_functions import RenderOrder
We’ll also need to change the Entity’s render_order
when they die.
monster.ai = None
monster.name = 'remains of ' + monster.name
+ monster.render_order = RenderOrder.CORPSE
monster.ai = None
monster.name = 'remains of ' + monster.name
monster.render_order = RenderOrder.CORPSE
And, you guessed it, make sure you import:
from render_functions import RenderOrder
* Note: We’re not changing the render_order
on the player when it
dies; we actually want that corpse on top so we’ll see it. It’s more
dramatic that way!
Now let’s implement the part in render_all
that will actually take
this new variable into account.
if fov_recompute:
...
+ entities_in_render_order = sorted(entities, key=lambda x: x.render_order.value)
- for entity in entities:
+ for entity in entities_in_render_order:
draw_entity(con, entity, fov_map)
...
if fov_recompute: ... entities_in_render_order = sorted(entities, key=lambda x: x.render_order.value) for entity in entities: for entity in entities_in_render_order: draw_entity(con, entity, fov_map) ...
Now the corpses will be drawn first, then the items (when we put them in), then the entities. This ensures we will see what’s most important first.
And we’re done! That was quite the chapter, but you survived! Run the project and see how long you can last in the now-deadly Dungeons of Doom! With an actual combat system, we’ve taken a pretty massive step towards having a real roguelike game on our hands.
If you want to see the code so far in its entirety, click here.