Complete Roguelike Tutorial, using python+libtcod, part 6

From RogueBasin
Revision as of 17:01, 18 August 2010 by Jotaf (talk | contribs) (more sections)
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 little 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?

We could just pretend 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 (not our case!), 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 things, or have a truly huge number of (mostly redundant) properties per object.

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

However, the separation is not that 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 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 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 MagicItem due to the rigid hierarchy is a bummer. Shuffling classes and code around to achieve these simple tasks is common with inheritance. We wanna mix and match freely! Hence, 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 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), defense and attack power.


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


It'll later be augmented with methods to attack and take damage. Then there's the BasicMonster component, which holds 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!'


Don't mind 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. Ok, 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 its position, or its name -- as you noticed earlier, BasicMonster 's take_turn method needs to know the object's name to display a proper message). In addition to holding the component, the Object will set the component's owner property to itself. The if lines 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. Actually, this is 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.


#create object representing the player
fighter_component = Fighter(hp=30, defense=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). I don't think they're necessary for the first few arguments of Object since you can easily deduce what they mean (name, color, etc). This is not news 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'll make your code much easier to maintain!

Now the monsters, they're defined in place_objects. Trolls will be obviously stronger than orcs. Monsters have 2 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", the rule is simple: 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 math, 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; we normalize it so it has the same direction but has a length of exactly 1 tile; 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.


    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; 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 chase you and try to hit you.

The whole code is available here.




The whole code is available here.

Go on to the next part.