Before we begin our regularly scheduled program, please update your 'colors' dictionary as follows:

    ...
    colors = {
        'dark_wall': (0, 0, 100),
        'dark_ground': (50, 50, 150),
        'light_wall': (130, 110, 50),
        'light_ground': (200, 180, 50),
        'desaturated_green': (63, 127, 63),
        'darker_green': (0, 127, 0),
        'dark_red': (191, 0, 0),
        'white': (255, 255, 255),
        'black': (0, 0, 0),
        'red': (255, 0, 0),
        'orange': (255, 127, 0),
        'light_red': (255, 114, 114),
        'darker_red': (127, 0, 0),
        'violet': (127, 0, 255),
        'yellow': (255, 255, 0),
        'blue': (0, 0, 255),
        'green': (0, 255, 0),
        'light_cyan': (114, 255, 255),
        'light_pink': (255, 114, 184)
    }
    ...

Adding health potions was a big step, but we won't stop there. Let's continue adding a few items, this time with a focus on offense. We'll add a few scrolls, which will give the player a one-time ranged attack. This gives the player a lot more tactical options to work with, and is definitely something you'll want to expand upon in your own game.

Let's start simple, with a spell that just hits the closest enemy. We'll create a scroll of lightning, which automatically targets an enemy nearby the player. Start by adding the function to item_functions.py:

def heal(*args, **kwargs):
    ...

def cast_lightning(*args, **kwargs):
    caster = args[0]
    colors = args[1]
    entities = kwargs.get('entities')
    game_map = kwargs.get('game_map')
    damage = kwargs.get('damage')
    maximum_range = kwargs.get('maximum_range')

    results = []

    target = None
    closest_distance = maximum_range + 1

    for entity in entities:
        if entity.fighter and entity != caster and game_map.fov[entity.x, entity.y]:
            distance = caster.distance_to(entity)

            if distance < closest_distance:
                target = entity
                closest_distance = distance

    if target:
        results.append({'consumed': True, 'target': target, 'message': Message('A lighting bolt strikes the {0} with a loud thunder! The damage is {1}'.format(target.name, damage))})
        results.extend(target.fighter.take_damage(damage))
    else:
        results.append({'consumed': False, 'target': None, 'message': Message('No enemy is close enough to strike.', colors.get('red'))})

    return results

Now let's add a chance for this scroll to drop on the map. Most of the items will still be health potions, but we'll sprinkle in a few lightning scrolls as well. In map_utils.py:

        ...
        if not any([entity for entity in entities if entity.x == x and entity.y == y]):
            item_chance = randint(0, 100)

            if item_chance < 70:
                item_component = Item(use_function=heal, amount=4)
                item = Entity(x, y, '!', colors.get('violet'), 'Healing Potion', render_order=RenderOrder.ITEM,
                              item=item_component)
            else:
                item_component = Item(use_function=cast_lightning, damage=20, maximum_range=5)
                item = Entity(x, y, '#', colors.get('yellow'), 'Lightning Scroll', render_order=RenderOrder.ITEM,
                              item=item_component)

Be sure to import cast_lightning at the top of the file.

...
from entity import Entity

from item_functions import cast_lightning, heal

from render_functions import RenderOrder
...

Lastly, we'll need to adjust our "use" call in engine.py, since our lightning spell is expecting more keyword arguments than we're currently passing.

            ...
            if game_state == GameStates.SHOW_INVENTORY:
                player_turn_results.extend(player.inventory.use(item, colors))
                player_turn_results.extend(player.inventory.use(item, colors, entities=entities, game_map=game_map))
            elif game_state == GameStates.DROP_INVENTORY:
                player_turn_results.extend(player.inventory.drop_item(item, colors))

Run the project now, and you should have a working lightning scroll. That was pretty easy!

*Tip: For testing, you may want to increase the maximum amount of items per room.

Needless to say, the spell would be much more usable if we were allowed to select the target. While we won't change the lightning spell, we should have another type of spell that allows targeting. Let's focus on creating a fireball spell, which will not only ask for a target, but also hit multiple enemies in a set radius.

We'll work backwards in this case, by starting with the end result (the "fireball" spell) and modifying everything else to make this work. Here's the fireball spell, which should go in item_functions.py:

...
def cast_lightning(*args, **kwargs):
    ...

def cast_fireball(*args, **kwargs):
    colors = args[1]
    entities = kwargs.get('entities')
    game_map = kwargs.get('game_map')
    damage = kwargs.get('damage')
    radius = kwargs.get('radius')
    target_x = kwargs.get('target_x')
    target_y = kwargs.get('target_y')

    results = []

    if not game_map.fov[target_x, target_y]:
        results.append({'consumed': False, 'message': Message('You cannot target a tile outside your field of view.',
                                                              colors.get('yellow'))})
        return results

    results.append({'consumed': True,
                    'message': Message('The fireball explodes, burning everything within {0} tiles!'.format(radius),
                                       colors.get('orange'))})

    for entity in entities:
        if entity.distance(target_x, target_y) <= radius and entity.fighter:
            results.append({'message': Message('The {0} gets burned for {1} hit points.'.format(entity.name, damage),
                                               colors.get('orange'))})
            results.extend(entity.fighter.take_damage(damage))

    return results

What do we need to do to make this function work? The most obvious thing is to pass the damage, radius, and target location. Damage and radius are easy; we can do those when we create the item in place_entities. The target is trickier, because we don't know that is until the player selects a tile after using the item.

We're going to need another game state for targeting. When the player selects a certain type of item, the game will ask him or her to select a location before proceeding. The player then can left-click on a location, or right-click to cancel, so we'll need a new set of input handlers as well.

Start with the easy part: Add a new game state to GameStates:

class GameStates(Enum):
    PLAYERS_TURN = 1
    ENEMY_TURN = 2
    PLAYER_DEAD = 3
    SHOW_INVENTORY = 4
    DROP_INVENTORY = 5
    TARGETING = 6

Now let's modify the input handlers. We'll add a function for the keys while we're targeting, and also add a generalized mouse handler, to know where the player clicks.

def handle_keys(user_input, game_state):
    if user_input:
        if game_state == GameStates.PLAYERS_TURN:
            return handle_player_turn_keys(user_input)
        elif game_state == GameStates.PLAYER_DEAD:
            return handle_player_dead_keys(user_input)
        elif game_state == GameStates.TARGETING:
            return handle_targeting_keys(user_input)
        elif game_state in (GameStates.SHOW_INVENTORY, GameStates.DROP_INVENTORY):
            return handle_inventory_keys(user_input)

    return {}
    ...


def handle_targeting_keys(user_input):
    if user_input.key == 'ESCAPE':
        return {'exit': True}

    return {}

def handle_player_dead_keys(key):
    ...


def handle_mouse(mouse_event):
    if mouse_event:
        (x, y) = mouse_event.cell

        if mouse_event.button == 'LEFT':
            return {'left_click': (x, y)}
        elif mouse_event.button == 'RIGHT':
            return {'right_click': (x, y)}

    return {}

If the player is in targeting mode, the only key we'll accept is Escape, which cancels the targeting. The mouse handler doesn't take the game state into account; it just tells the engine if the left or right mouse button was clicked. The engine will have to decide what to do with that. Modify engine.py to accept the mouse inputs:

        for event in tdl.event.get():
            if event.type == 'KEYDOWN':
                user_input = event
                break
            elif event.type == 'MOUSEMOTION':
                mouse_coordinates = event.cell
            elif event.type == 'MOUSEDOWN':
                user_mouse_input = event
                break
        else:
            user_input = None
            user_mouse_input = None

        if not user_input:
        if not (user_input or user_mouse_input):
            continue

        action = handle_keys(user_input, game_state)
        mouse_action = handle_mouse(user_mouse_input)

        move = action.get('move')
        pickup = action.get('pickup')
        show_inventory = action.get('show_inventory')
        inventory_index = action.get('inventory_index')
        exit = action.get('exit')
        fullscreen = action.get('fullscreen')

        left_click = mouse_action.get('left_click')
        right_click = mouse_action.get('right_click')

        player_turn_results = []

Of course, we need to import handle_mouse into engine.py:

...
from game_states import GameStates
from input_handlers import handle_keys, handle_mouse
from map_objects.game_map import GameMap
...

So how do we even know what types of items need to select a target? We can add an attribute to the Item component which will tell us. We should also add a message, which will display when the user activates the item, to inform the user that a target needs to be selected. Modify the __init__ function in Item like this:

class Item:
    def __init__(self, use_function=None, targeting=False, targeting_message=None, **kwargs):
        self.use_function = use_function
        self.targeting = targeting
        self.targeting_message = targeting_message
        self.function_kwargs = kwargs

Because we're setting the values of targeting and targeting_message to None by default, we don't have to worry about changing the items we've already made.

We'll need to change our use function in Inventory to take the targeting variable into account. If the item needs a target, we should return a result that tells the engine that, and not use the item. If not, we proceed as before. Add a new "if" statement to use, and wrap the previous code section in the "else" clause, like this:

    def use(self, item_entity, colors, **kwargs):
        results = []

        item_component = item_entity.item

        if item_component.use_function is None:
            results.append({'message': Message('The {0} cannot be used'.format(item_entity.name), colors.get('yellow'))})
        else:
            if item_component.targeting and not (kwargs.get('target_x') or kwargs.get('target_y')):
                results.append({'targeting': item_entity})
            else:
                kwargs = {**item_component.function_kwargs, **kwargs}
                item_use_results = item_component.use_function(self.owner, colors, **kwargs)

                for item_use_result in item_use_results:
                    if item_use_result.get('consumed'):
                        self.remove_item(item_entity)

                results.extend(item_use_results)

        return results

So basically, we check if the item has "targeting" set to True, and if it does, whether or not we received the target_x and target_y variables. If we didn't we can assume that the target has not yet been selected, and the game state needs to switch to targeting. If it did, we can use the item like normal.

Now let's modify the engine to handle this new result type. Note that this result returns the item entity to the engine. That's because the engine will need to "remember" which item was selected in the first place. Therefore, we'll need a new variable right before the main game loop to keep track of the targeting item that was selected.

    ...
    game_state = GameStates.PLAYERS_TURN
    previous_game_state = game_state

    targeting_item = None

    while not tdl.event.is_window_closed():
        ...
            message = player_turn_result.get('message')
            dead_entity = player_turn_result.get('dead')
            item_added = player_turn_result.get('item_added')
            item_consumed = player_turn_result.get('consumed')
            item_dropped = player_turn_result.get('item_dropped')
            targeting = player_turn_result.get('targeting')
            ...

            if item_dropped:
                entities.append(item_dropped)

                game_state = GameStates.ENEMY_TURN

            if targeting:
                previous_game_state = GameStates.PLAYERS_TURN
                game_state = GameStates.TARGETING

                targeting_item = targeting

                message_log.add_message(targeting_item.item.targeting_message)

Now our game state will switch to targeting when we select an item from the inventory that needs it. Note that we're doing something a little strange with the previous game state; we're setting it to the player's turn rather than the actual previous state. This is so that cancelling the targeting will not reopen the inventory screen.

Let's now do something with the left and right clicks we added in before. If the player left clicks while in targeting, we'll activate the use function again, this time with the target variables. If the user right clicks, we'll cancel the targeting. We can also add the cancel targeting on Escape now.

        ...
        if inventory_index is not None and previous_game_state != GameStates.PLAYER_DEAD and inventory_index < len(
                player.inventory.items):
            ...

        if game_state == GameStates.TARGETING:
            if left_click:
                target_x, target_y = left_click

                item_use_results = player.inventory.use(targeting_item, colors, entities=entities, game_map=game_map,
                                                        target_x=target_x, target_y=target_y)
                player_turn_results.extend(item_use_results)
            elif right_click:
                player_turn_results.append({'targeting_cancelled': True})

        if exit:
            if game_state in (GameStates.SHOW_INVENTORY, GameStates.DROP_INVENTORY):
                game_state = previous_game_state
            elif game_state == GameStates.TARGETING:
                player_turn_results.append({'targeting_cancelled': True})
            else:
                return True

        if fullscreen:
            ...

Add the following to make the target cancellation revert the game state:

            targeting = player_turn_result.get('targeting')
            targeting_cancelled = player_turn_result.get('targeting_cancelled')

            if message:
                ...

            if targeting_cancelled:
                game_state = previous_game_state

                message_log.add_message(Message('Targeting cancelled'))

Finally, let's add the fireball scroll to the map. Modify place_entities like this:

            ...
            item_chance = randint(0, 100)

            if item_chance < 70:
                item_component = Item(use_function=heal, amount=4)
                item = Entity(x, y, '!', colors.get('violet'), 'Healing Potion', render_order=RenderOrder.ITEM,
                              item=item_component)
            elif item_chance < 85:
                item_component = Item(use_function=cast_fireball, targeting=True, targeting_message=Message(
                    'Left-click a target tile for the fireball, or right-click to cancel.', colors.get('light_cyan')),
                                      damage=12, radius=3)
                item = Entity(x, y, '#', colors.get('red'), 'Fireball Scroll', render_order=RenderOrder.ITEM,
                              item=item_component)
            else:
                item_component = Item(use_function=cast_lightning, damage=20, maximum_range=5)
                item = Entity(x, y, '#', colors.get('yellow'), 'Lightning Scroll', render_order=RenderOrder.ITEM,
                              item=item_component)

You'll need to import both cast_fireball and Message:

...
from entity import Entity

from game_messages import Message

from item_functions import cast_fireball, cast_lightning, heal

from render_functions import RenderOrder
...

One change we need to make for cast_fireball to work: We need a distance function in Entity, to get the distance between the entity and an arbitrary point.

    def move_towards(self, target_x, target_y, game_map, entities):
        ...

    def distance(self, x, y):
        return math.sqrt((x - self.x) ** 2 + (y - self.y) ** 2)

    def distance_to(self, other):
        ...

Run the project now, and you should have a functioning fireball spell! Be careful though, the player can get damaged by this spell if you cast it too close to yourself!

Let's add one more spell for fun: confusion. This will involve modifying the target's AI for a few turns, and setting it back to normal once the spell ends.

We'll begin by adding the confused AI, to ai.py:

from random import randint

from game_messages import Message


class BasicMonster:
    ...


class ConfusedMonster:
    def __init__(self, previous_ai, number_of_turns=10):
        self.previous_ai = previous_ai
        self.number_of_turns = number_of_turns

    def take_turn(self, target, game_map, entities):
        results = []

        if self.number_of_turns > 0:
            random_x = self.owner.x + randint(0, 2) - 1
            random_y = self.owner.y + randint(0, 2) - 1

            if random_x != self.owner.x and random_y != self.owner.y:
                self.owner.move_towards(random_x, random_y, game_map, entities)

            self.number_of_turns -= 1
        else:
            self.owner.ai = self.previous_ai
            results.append({'message': Message('The {0} is no longer confused!'.format(self.owner.name))})

        return results

The class gets initialized with a number of turns that the entity is confused for. It also keeps track of what the entity's actual AI is, so that it can be switched back when the confusion wears off. For the take_turn method, the entity moves randomly (or not at all), and one turn gets taken off the timer. Once the timer hits 0, the entity is no longer confused, and goes back to its previous AI.

Unfortunately this won't work as is, because there's a little bug in move_towards. That function assumes that the destination you're giving it is valid, and in this case, we're giving it a random location, so it might not be.

The fix is pretty easy though: we just need to check if the path is not empty. Open up entity.py and modify it like this:

    ...
    def move_towards(self, target_x, target_y, game_map, entities):
        path = game_map.compute_path(self.x, self.y, target_x, target_y)

        if path:
            dx = path[0][0] - self.x
            dy = path[0][1] - self.y

            if game_map.walkable[path[0][0], path[0][1]] and not get_blocking_entities_at_location(entities, self.x + dx,
                                                                                                   self.y + dy):
                self.move(dx, dy)

    def distance(self, x, y):
        ...

Now for the confusion spell. Add the following to item_functions.py

def cast_fireball(*args, **kwargs):
    ...

def cast_confuse(*args, **kwargs):
    colors = args[1]
    entities = kwargs.get('entities')
    game_map = kwargs.get('game_map')
    target_x = kwargs.get('target_x')
    target_y = kwargs.get('target_y')

    results = []

    if not game_map.fov[target_x, target_y]:
        results.append({'consumed': False, 'message': Message('You cannot target a tile outside your field of view.',
                                                              colors.get('yellow'))})
        return results

    for entity in entities:
        if entity.x == target_x and entity.y == target_y and entity.ai:
            confused_ai = ConfusedMonster(entity.ai, 10)

            confused_ai.owner = entity
            entity.ai = confused_ai

            results.append({'consumed': True, 'message': Message('The eyes of the {0} look vacant, as he starts to stumble around!'.format(entity.name),
                                                                 colors.get('light_green'))})

            break
    else:
        results.append({'consumed': False, 'message': Message('There is no targetable enemy at that location.',
                                                              colors.get('yellow'))})

    return results

You'll need to import the ConfusedMonster class to the top of the file:

from components.ai import ConfusedMonster

from game_messages import Message
...

Finally, we'll put the scroll on the map. First, import the cast_confuse function:

...
from game_messages import Message

from item_functions import cast_confuse, cast_fireball, cast_lightning, heal

from render_functions import RenderOrder
...

We'll also modify the chances of our scrolls, so that each one has a 10% chance of spawning.

            if item_chance < 70:
                item_component = Item(use_function=heal, amount=4)
                item = Entity(x, y, '!', colors.get('violet'), 'Healing Potion', render_order=RenderOrder.ITEM,
                              item=item_component)
            elif item_chance < 85:
            elif item_chance < 80:
                item_component = Item(use_function=cast_fireball, targeting=True, targeting_message=Message(
                    'Left-click a target tile for the fireball, or right-click to cancel.', colors.get('light_cyan')),
                                      damage=12, radius=3)
                item = Entity(x, y, '#', colors.get('red'), 'Fireball Scroll', render_order=RenderOrder.ITEM,
                              item=item_component)
            elif item_chance < 90:
                item_component = Item(use_function=cast_confuse, targeting=True, targeting_message=Message(
                    'Left-click an enemy to confuse it, or right-click to cancel.', colors.get('light_cyan')))
                item = Entity(x, y, '#', colors.get('light_pink'), 'Confusion Scroll', render_order=RenderOrder.ITEM,
                              item=item_component)

Run the project, and you should be able to cast confusion on enemies. Enemies who are confused will waste their turns either moving randomly, or staying in one spot.

That's all for today. We now have 3 different types of scrolls the player can utilize against enemies. Feel free to try adding more scrolls and spells as you see fit.

If you want to see the code so far in its entirety, click here.

Click here to move on to the next part of this tutorial.