Complete Roguelike Tutorial, using python+libtcod, part 6

From RogueBasin
Revision as of 16:58, 22 September 2010 by Teddy Leach (talk | contribs)
Jump to navigation Jump to search

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

Going Berserk!


The components

Finally, it's time to bash some orc helmets into tiny metal pancakes! Combat is a big turning point in the development of any game: it separates the game from the tech demo. Although dungeon exploration is interesting, the simplest fight can be far more fun, and you may even find yourself playing your game more than you code it!

We have a big design decision ahead of us. Until now, the Object class was enough to hold the properties of both the player and the enemies. But as we develop further, many properties will make sense for one kind of Object, but not for another. How do we solve this?

Well, we could just pretend that this is not a problem. It's called the data-driven approach and is used by many Roguelikes, especially the oldest ones. It has the advantages of playing nicely with the rigid data structures of C (which is not our problem!), and the properties of all objects can be edited in a big table or spreadsheet. But you either have to limit the number of properties, which can result in all objects seeming like minor variations of the same thing, or have a truly huge number of mostly redundant properties per object. This is, of course, not good for us.

The other popular alternative is inheritance. You define a hierarchy of parent (a.k.a. base) and child (a.k.a. derived) classes. Child/derived classes (like Item or Monster), in addition to their own properties, receive the properties from their parent classes (such as Object). This reduces redundancy, and there's a seemingly clean separation between different classes.

However, the separation is not particularly clean, since the properties of parent classes are 'pasted' on the same space as the child's properties; their properties can conflict if they share names. And there's also the temptation to define deep hierarchies of classes. As you develop further, your hierarchy will grow to extreme lengths (such as Object > Item > Equipment > Weapon > Melee weapon > Blunt weapon > Mace > Legendary mace of Deep Hierarchies). Each level can add just a tiny bit of functionality over the last one.

The fact that a Mace can't be both a Weapon and a Magic Item due to the rigid hierarchy is a bummer. Shuffling classes and code around to achieve these simple tasks is common with inheritance. We want to be able to mix and match freely! Hence, we have inheritance's older, but often forgotten, cousin: composition. It has none of the disadvantages listed above.


It's dead simple: there's the Object class, and some component classes. A component class defines extra properties and methods for an Object that needs them. Then you just slap an instance of the component class as a property of the Object; it now "owns" the component. It doesn't even require special functions or code! Let's see how it works.


Our first component will be the Fighter. Any object that can fight or be attacked must have it. It holds hit points, maximum hit points (for healing), defence and attack power.


class Fighter:
    #combat-related properties and methods (monster, player, NPC).
    def __init__(self, hp, defence, power):
        self.max_hp = hp
        self.hp = hp
        self.defence = defence
        self.power = power


It'll later be augmented with methods to attack and take damage.

Then there's the BasicMonster component, which holds basic AI routines. You can create other AI components (say, for ranged combat) and use them for some monsters. We'll define a take_turn method; as long as a component defines this method, it's a valid alternative to BasicMonster. For now it just prints a debug message:


class BasicMonster:
    #AI for a basic monster.
    def take_turn(self):
        print 'The ' + self.owner.name + ' growls!'


Ignore the reference to self.owner -- it's just the Object instance that owns this component, and is initialized elsewhere. We'll get to that in a moment. So how do we associate components with an Object? It's simple: create a Fighter instance, and/or a BasicMonster instance, and pass them as parameters when initializing the Object:


    def __init__(self, x, y, char, name, color, blocks=False, fighter=None, ai=None):


Notice that all components are optional; they can be None if you don't want them. Then they're stored as properties of the object, for example with self.fighter = fighter. Also, since a component will often want to deal with its owner Object, it has to "know" who it is (for example, to get it's position, or it's name. As you noticed earlier, BasicMonster 's take_turn method needs to know the object's name to display a proper message). So, in addition to holding the component, the Object will set the component's owner property to itself. The if lines just make sure this happens only if the component is actually defined.


        self.fighter = fighter
        if self.fighter:  #let the fighter component know who owns it
            self.fighter.owner = self
        
        self.ai = ai
        if self.ai:  #let the AI component know who owns it
            self.ai.owner = self


This may look a bit weird, but now we can follow these properties around to go from a component (self), to its owner object (self.owner), to a different one of its components (self.owner.ai), allowing us to do all sorts of funky stuff! Most other systems don't have this kind of flexibility for free. This is actually the most complicated code that composition needs; the rest will be pure game logic!

OK, now it's time to decide on some stats for the monsters and the player! First up, the player. Just create a Fighter component with the stats you choose, and set it as the fighter parameter when creating the player object. Place it above the main loop:


#create object representing the player
fighter_component = Fighter(hp=30, defence=2, power=5)
player = Object(0, 0, '@', 'player', libtcod.white, blocks=True, fighter=fighter_component)


Here, I decided to use keyword arguments to make it clear what the different stats are (Fighter(30, 2, 5) is hard to interpret). They're not necessary for the first few arguments of Object since you can easily deduce what they mean (name, color, etc). This is common sense for most people, but I'll say it anyway: always try to strike a good balance between short and readable code; in places where you can't, pepper it with lots explanatory comments. It will make your code much easier to maintain!

Now, the monsters are defined in place_objects. Trolls will be obviously stronger than orcs. Monsters have two components: Fighter and BasicMonster.


                #create an orc
                fighter_component = Fighter(hp=10, defense=0, power=3)
                ai_component = BasicMonster()
                
                monster = Object(x, y, 'o', 'orc', libtcod.desaturated_green,
                    blocks=True, fighter=fighter_component, ai=ai_component)
            else:
                #create a troll
                fighter_component = Fighter(hp=16, defense=1, power=4)
                ai_component = BasicMonster()
                
                monster = Object(x, y, 'T', 'troll', libtcod.darker_green,
                    blocks=True, fighter=fighter_component, ai=ai_component)


Keyword arguments come to the rescue again, since in the future most objects will have only a handful of all possible components. This way you can set only the ones you want, even if they're out-of-order!


AI

It's time to make our monsters move and kick about! It's not really "artificial intelligence", as these guys will be pretty thick. The rule for them is: if you see the player, chase him. Actually, we'll assume that the monster can see the player if its within the player's FOV.

We'll create a chasing method (move_towards) in the Object class, which can be used to simplify all your AI functions. It has a bit of vector mathematics, but if you're not into that you can use it without understanding how it works. Basically, we get a vector from the object to the target, then we normalize it so it has the same direction but has a length of exactly 1 tile, and then we round it so the resulting vector is integer (instead of fractional as usual - so dx and dy can only take the values 0, -1 or +1). The object then moves by this amount. it's complicated, but as I say you can use it without understanding it.


    def move_towards(self, target_x, target_y):
        #vector from this object to the target, and distance
        dx = target_x - self.x
        dy = target_y - self.y
        distance = math.sqrt(dx ** 2 + dy ** 2)
        
        #normalize it to length 1 (preserving direction), then round it and
        #convert to integer so the movement is restricted to the map grid
        dx = int(round(dx / distance))
        dy = int(round(dy / distance))
        self.move(dx, dy)


Another useful Object method returns the distance between two objects, using the common distance formula. You need import math at the top of the file in order to use the square root function.


    def distance_to(self, other):
        #return the distance to another object
        dx = other.x - self.x
        dy = other.y - self.y
        return math.sqrt(dx ** 2 + dy ** 2)


As mentioned earlier, the behavior is simply "if you see the player, chase him". Here's the full code for the BasicMonster class that does it. The monster is only active if its within the player's FOV.


class BasicMonster:
    #AI for a basic monster.
    def take_turn(self):
        #a basic monster takes its turn. If you can see it, it can see you
        monster = self.owner
        if libtcod.map_is_in_fov(fov_map, monster.x, monster.y):
            
            #move towards player if far away
            if monster.distance_to(player) >= 2:
                monster.move_towards(player.x, player.y)
                
            #close enough, attack! (if the player is still alive.)
            elif player.fighter.hp > 0:
                print 'The attack of the ' + monster.name + ' bounces off your shiny metal armor!'


That's not terribly smart, but it gets the job done! You can, of course, improve it a lot; for now we'll just leave it like this and continue working on combat. The last thing is to call take_turn for any intelligent monsters from the main loop:


    if game_state == 'playing' and player_action != 'didnt-take-turn':
        for object in objects:
            if object.ai:
                object.ai.take_turn()


Ready to test! The annoying little buggers will now chase you and try to hit 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. The Fighter class will have a method to take damage:


    def take_damage(self, damage):
        #apply damage if possible
        if damage > 0:
            self.hp -= damage


In the next section we'll modify it to also handle deaths. Then there's the method to attack another object:


    def attack(self, target):
        #a simple formula for attack damage
        damage = self.power - target.fighter.defense
        
        if damage > 0:
            #make the target take some damage
            print self.owner.name.capitalize() + ' attacks ' + target.name + ' for ' + str(damage) + ' hit points.'
            target.fighter.take_damage(damage)
        else:
            print self.owner.name.capitalize() + ' attacks ' + target.name + ' but it has no effect!'


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.

Now to give them some use. In the BasicMonster 's take_turn method, replace the dummy print line for the monster's attack with:


                monster.fighter.attack(player)


And the dummy print line for the player's attack, in player_move_or_attack, with:


        player.fighter.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
    libtcod.console_set_foreground_color(con, libtcod.white)
    libtcod.console_print_left(0, 1, SCREEN_HEIGHT - 2, libtcod.BKGND_NONE,
        'HP: ' + str(player.fighter.hp) + '/' + str(player.fighter.max_hp))


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 class. Since different objects have different behaviours when killed, the Fighter class 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 death_function is passed as a parameter when creating a Fighter instance.


    def __init__(self, hp, defence, power, death_function=None):
        self.death_function = death_function


It is then called by the take_damage method, in the event that the HP reaches 0:


            #check for death. if there's a death function, call it
            if self.hp <= 0:
                function = self.death_function
                if function is not None:
                    function(self.owner)


Now we'll define some fun death functions! They just change the object so it looks like a corpse, as well as printing some messages. The player's death also changes the game state, so he can't move or attack any more.


def player_death(player):
    #the game ended!
    global game_state
    print 'You died!'
    game_state = 'dead'
    
    #for added effect, transform the player into a corpse!
    player.char = '%'
    player.color = libtcod.dark_red

def monster_death(monster):
    #transform it into a nasty corpse! it doesn't block, can't be
    #attacked and doesn't move
    print monster.name.capitalize() + ' is dead!'
    monster.char = '%'
    monster.color = libtcod.dark_red
    monster.blocks = False
    monster.fighter = None
    monster.ai = None
    monster.name = '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 assign these behaviours to the player and monsters, pass the extra parameter death_function=monster_death when creating the monsters' Fighter component, in place_objects; and also when creating the player's Fighter component before the main loop (death_function=player_death).

We'll add a few details to polish it up. For the impatient, however: it's ready to play now! You may notice some glitches though. In player_move_or_attack, we only want the player to attack objects that have a Fighter component. So change the if object.x == x ... line to:


        if object.fighter and object.x == x and object.y == y:


There's also currently the issue that, when the player walks over a corpse, sometimes it's drawn over the player! There's no guarantee that the player is the object that is drawn last. So we need to draw all other objects first, and only then the player. Just change the rendering loop in render_all to:


    #draw all objects in the list, except the player. we want it to
    #always appear over all other objects! so it's drawn later.
    for object in objects:
        if object != player:
            object.draw()
    player.draw()


The same thing also happens with monsters -- a monster corpse being drawn over another monster. To fix it, create a method in the Object class that moves it to the start of the list, so it's drawn first:


    def send_to_back(self):
        #make this object be drawn first, so all others appear above it if they're in the same tile.
        global objects
        objects.remove(self)
        objects.insert(0, self)


And call it somewhere in the monster_death function:


    monster.send_to_back()


It's finally ready to play, and it actually feels like a game! It was 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.


The whole code is available here.

Go on to the next part.