Complete Roguelike Tutorial, using python+libtcod, part 9

From RogueBasin
Revision as of 21:38, 18 December 2010 by Jotaf (talk | contribs) (page created)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search

This is part of a series of tutorials; the main page can be found here.



Spells and ranged combat


Magic scrolls: the Lightning Bolt

The inventory we implemented has lots of untapped potential; I encourage you to explore it fully and create as many different items as possible! (Though the special category of items that can be equipped, like swords and armor, will be done in a later section.) To get the ball rolling, we'll start with some magic scrolls the player can use to cast spells. We'll sample a few different spell types; I'm sure you can then create tons of variants from these little examples.

The first spell is a lightning bolt that damages the nearest enemy. It's simple to code because it doesn't involve any targetting. On the other hand, it creates some interesting tactical challenges: if the nearest enemy is not the one you want to hit (for example, it's too weak to waste the spell on it), you have to maneuver into a good position. Just modify the place_objects function to choose at random between a healing potion and a lightning bolt scroll, the same way it's done with monsters:


            dice = libtcod.random_get_int(0, 0, 100)
            if dice < 70:
                #create a healing potion (70% chance)
                item_component = Item(use_function=cast_heal)
                
                item = Object(x, y, '!', 'healing potion', libtcod.violet, item=item_component)
            else:
                #create a lightning bolt scroll (30% chance)
                item_component = Item(use_function=cast_lightning)
                
                item = Object(x, y, '#', 'scroll of lightning bolt', libtcod.light_yellow, item=item_component)


When used, the new item calls the cast_lightning function. It's quite simple, and many of the spells will be equally short if you define some functions for common tasks. One of those common tasks is finding the nearest monster up to a maximum range, which we'll get to in a second; but first, the lightning bolt spell:


def cast_lightning():
    #find closest enemy (inside a maximum range) and damage it
    monster = closest_monster(LIGHTNING_RANGE)
    if monster is None:  #no enemy found within maximum range
        message('No enemy is close enough to strike.', libtcod.red)
        return 'cancelled'
    
    #zap it!
    message('A lighting bolt strikes the ' + monster.name + ' with a loud thunder! The damage is '
        + str(LIGHTNING_DAMAGE) + ' hit points.', libtcod.light_blue)
    monster.fighter.take_damage(LIGHTNING_DAMAGE)


It's a plain spell but an imaginative message can always give it some flavor! It returns a special string if cancelled to prevent the item from being destroyed in that case, like the healing potion. There are also a couple of new constants that have to be defined: LIGHTNING_DAMAGE = 20 and LIGHTNING_RANGE = 5.

Now the closest_monster function. We just need to loop through all the monsters, and keep track of the closest one so far (and its distance). By initializing that distance at a bit more than the maximum range, any monster farther away is rejected. We also check that it's in FOV, so the player can't cast a spell through walls.


def closest_monster(max_range):
    #find closest enemy, up to a maximum range, and in the player's FOV
    closest_enemy = None
    closest_dist = max_range + 1  #start with (slightly more than) maximum range
    
    for object in objects:
        if object.fighter and not object == player and libtcod.map_is_in_fov(fov_map, object.x, object.y):
            #calculate distance between this object and the player
            dist = player.distance_to(object)
            if dist < closest_dist:  #it's closer, so remember it
                closest_enemy = object
                closest_dist = dist
    return closest_enemy


This makes use of the distance_to method we wrote earlier for the AI. Alright, the lightning bolt is done! If you have one you can take down a Troll with a single hit, sparing you from a lot of damage.


Spells that manipulate monsters: Confusion

There are many direct damage variants of the Lightning Bolt spell. So we'll move on to a different sort of spell: one that affects the monsters' actions. This can be done by replacing their AI with a different one, that makes it do something different -- run away in fear, stay knocked out for a few turns, even fight on the player's side for a while!

My choice was a Confusion spell, that makes the monster move around randomly, and not attack the player. The AI component that does this is very similar to a regular one, since it defines a take_turn method. It could look like this:


class ConfusedMonster:
    #AI for a confused monster.
    def take_turn(self):
        #move in a random direction
        self.owner.move(libtcod.random_get_int(0, -1, 1), libtcod.random_get_int(0, -1, 1))


Which simply moves in a random direction (random X and Y displacements between -1 and 1).

It shouldn't go on forever of course, and after the spell runs out the previous AI must restored. So it must accept the previous AI component as an argument to store it. It must also keep track of the number of turns left until the effect ends:


class ConfusedMonster:
    #AI for a temporarily confused monster (reverts to previous AI after a while).
    def __init__(self, old_ai, num_turns=CONFUSE_NUM_TURNS):
        self.old_ai = old_ai
        self.num_turns = num_turns
    
    def take_turn(self):
        if self.num_turns > 0:  #still confused...
            #move in a random direction, and decrease the number of turns confused
            self.owner.move(libtcod.random_get_int(0, -1, 1), libtcod.random_get_int(0, -1, 1))
            self.num_turns -= 1
            
        else:  #restore the previous AI (this one will be deleted because it's not referenced anymore)
            self.owner.ai = self.old_ai
            message('The ' + self.owner.name + ' is no longer confused!', libtcod.red)


I defined the new constant CONFUSE_NUM_TURNS = 10. Now, the actual scroll that uses this AI! For it to appear in the dungeon it must be added to place_objects. Notice that the chance of getting a lightning bolt scroll must change:


            elif dice < 70+15:
                #create a lightning bolt scroll (15% chance)
                item_component = Item(use_function=cast_lightning)
                
                item = Object(x, y, '#', 'scroll of lightning bolt', libtcod.light_yellow, item=item_component)
            else:
                #create a confuse scroll (15% chance)
                item_component = Item(use_function=cast_confuse)
                
                item = Object(x, y, '#', 'scroll of confusion', libtcod.light_yellow, item=item_component)


I'm making all scrolls look the same, but in your game that's up to you. The cast_confuse function can now be defined. It hits the closest monster for now, like the lightning bolt; later we'll allow targetting.


def cast_confuse():
    #find closest enemy in-range and confuse it
    monster = closest_monster(CONFUSE_RANGE)
    if monster is None:  #no enemy found within maximum range
        message('No enemy is close enough to confuse.', libtcod.red)
        return 'cancelled'


This uses the constant CONFUSE_RANGE = 8. Components are "plug & play" so they only take a couple of lines of code to replace! What this does is assign a new "confused AI" component to the monster, giving it the previous AI component as an argument. Then its "owner" property is set to the monster, since the new component doesn't know what monster it belongs to.

Setting the "owner" has to be done every time you replace a component in the middle of the game. To top it off, print a nice message describing what happened.


    #replace the monster's AI with a "confused" one; after some turns it will restore the old AI
    old_ai = monster.ai
    monster.ai = ConfusedMonster(old_ai)
    monster.ai.owner = monster  #tell the new component who owns it
    message('The eyes of the ' + monster.name + ' look vacant, as he starts to stumble around!', libtcod.light_green


This seems like a neat way to interrupt a monster's actions in reaction to something. It is, but not for every situation! You could create a full finite state machine by just swapping AI components, but such a system would be very hard to debug. Instead, for most types of AI that have different states, you can simply have a "state" property in the AI component, like this:


class DragonAI:
    def __init__(self):
        self.state = 'chasing'
    
    def take_turn(self):
        if self.state == 'chasing': ...
        elif self.state == 'charging-fire-breath': ...


Swapping AI components is most useful for unexpected interruptions, such as when a spell is cast. The BasicMonster or DragonAI shouldn't know what to do when confused; that depends on the spell. They do know, however, what to do in normal situations (attacking, etc), and those behaviors usually don't belong in other AI components; if a single AI is composed of too many small interacting parts, they can be harder to maintain. This is just a piece of advice though, and in programming common sense always wins over any hard rules.


...