February 28, 2011

ISSUE 7: Do you read me, HAL?

4. Coding It!

As you probably guessed, most of what we need to do is in volume1/enemies.py. Open up the file and edit it to look like this:

from player import AlliedFlanker
import random, math

# simple enumerated type
class States:
    pursue, attack, flyby, flee = range(4) 

class EnemyFlanker(AlliedFlanker):
    def __init__(self,loader,scenegraph,taskMgr,skillLevel):
        # take an ally and make it join the dark side...
        AlliedFlanker.__init__(self,loader,scenegraph,taskMgr)
        tex = loader.loadTexture('volume1/models/enemyflanker.jpg')
        self.player.setTexture(tex,1)
        self.player.setScale(0.5)
        self.skillLevel = skillLevel
        self.state = States.pursue
        self.engineSound.stop()

        self.speed = 0.0
        self.flybydistance = 0.0
        self.pursuedistance = 0.0
        self.attackdistance = 0.0
        origspeed = self.maxspeed

        # experiment with the random values to see different behaviour
        if self.skillLevel == 1:
            self.maxspeed = self.maxspeed * random.random()
            if self.maxspeed < ((1.0/5.0) * origspeed):
                self.maxspeed = self.maxspeed + ((1.0/5.0)*origspeed)
            self.flybydistance = 20.0 + (10.0 * random.random())
            self.attackdistance = self.flybydistance+100.0+(100.0*random.random())
            self.pursuedistance = self.attackdistance * 2.5

        # print "ENEMY: "+str(self.maxspeed)
        self.clockwise = True
        self.heightmatch = 10 + (90 * random.random())

    def teleport(self,boundingBox):
        # random X/Y within bounds
        self.player.setX(random.randint(0,boundingBox))
        self.player.setY(random.randint(0,boundingBox))
        self.player.setZ(100)

    def update(self,boundingBox,playerPos):
        validMove = True
        self.calculate()
        if self.exploding == False:
            self.ai(playerPos)
            validMove = self.move(boundingBox, False, False)
        return validMove

    def ai(self,playerPos):
        # Trig
        distance = math.sqrt (math.pow(self.player.getX()-playerPos[0],2)+ \
                   math.pow(self.player.getY()-playerPos[1],2)+ \
                   math.pow(self.player.getZ()-playerPos[2],2))

        if (self.state == States.pursue):
            self.heightMatch(playerPos)
            self.accelerate()
            self.player.lookAt(playerPos) # cheating badly!
            self.player.setH(self.player.getH()-90)

            if (distance < self.attackdistance):
                self.state = States.attack

        elif (self.state == States.attack):
            self.heightMatch(playerPos)
            self.player.lookAt(playerPos)
            self.player.setH(self.player.getH()-90)
            if (distance < self.flybydistance):
                if random.randint(0,1) == 0:
                    self.clockwise = False
                else:
                    self.clockwise = True
                self.state = States.flyby
            elif (distance > self.pursuedistance):
                self.state = States.pursue
            # Hook to FLEE will go here when we have player/enemy damage
            # will also add attacking at same time

        elif (self.state == States.flyby):
            self.heightMatch(playerPos)
            if (self.speed > (self.maxspeed/2)):
                    self.brake()
            if (self.clockwise != True):
                self.bankRight()
            elif (self.clockwise == True):# and self.player.getP() < 40.0):
                self.bankLeft()
            if (distance > self.pursuedistance):
                self.state = States.pursue
            # Hook to FLEE will go here when we have player/enemy damage

    def heightMatch(self,playerPos):
        diffZ = self.player.getZ() - playerPos[2]
        if (diffZ < -self.heightmatch):
           self.climb()
        elif (diffZ > self.heightmatch):
           self.dive()

We will now go through this code. First, we create a new class called ‘States’. We then add four class variables representing our states (pursue, attack, flyby, flee) each set with a value returned via range(4) (we first covered Python ranges in Issue 4). This sets the variable values as follows:

  • pursue = 0
  • attack = 1
  • flyby = 2
  • flee = 3

What we are trying to do is create what is called an ‘enumerated type‘. Python does not directly support enumerated types hence the new class to achieve something ‘like’ an enumeration. An enumerated type is one that the programmer defines. For example, if we were programming traffic lights it might be nice to have a data/variable type that holds the value Red, Amber or Green.

The code might also look strange because we declare the variables outside of any method whereas in MGF to date we have only ever created member variables for a class inside its methods using the ‘self’ keyword. So what’s the difference?

Such variables are called class or static variables. They exist at the class level and can be accessed without creating an object. Look later in the code and note how we refer to ‘States.pursue’, ‘States.flee’ etc. without creating an object. You still can create an object if you like. It’s important to note that if you create an object it has its own separate copy/instance of each variable. This is unusual to those coming from a C++ or Java background where static variables are ‘shared’.

Next, our EnemyFlanker class. Based on how it was previously and what you have previously learnt about Python this should be pretty much self-explanatory. We would, however, point out the following:

  1. Engine sound. We turn it off. All it will achieve when playing is multiple aircraft engine sounds. We need to address enemy sounds differently – based on locality (distance) to the player.
  2. States. As discussed above, the use of a basic enumerated type.
  3. Random Variables. As discussed in the previous section, we will use some random variables to achieve dynamic behaviour.

Next, the teleport method which we introduced in the last issue. As its name suggests, it teleports the enemy to a random location on the map.

The ‘update’ method calls ‘calculate’ (the same as updateTask in game.py does for the main player) and then a new method ‘ai’. The AI method itself follows next in the code. Looking at it, you should see some resemblance in its logic to what we had in our FSM diagram previously.

Note we have not coded for ‘flee’ yet but have left comments where it will go. This is simply because we do not yet have any notion of player or enemy damage in our game! For the same reason, the ‘attack’ mode does not actually ‘fire weapons’ yet! Again, the code should make sense by itself at this stage with the interesting highlights as:

  • Note the comment ‘cheating badly!’. We admit it, we cheat! After a flyby/switch back to ‘pursue’ we rely on a call to ‘lookAt’. What this actually means, is that if the enemy is flying away from the player and then switched back to ‘pursue’ it will do a U-Turn (180 degrees) instantly without maneuvering! The good news is that this little trick works well because the enemy is far from view when it happens. Feel free to attempt something more elegant here!
  • It makes use of a ‘heightMatch’ method which is defined next in the code. It also makes use of a little bit of trigonometry to calculate the distance between the enemy and the player. You might recall the Pythagoras Theorem from School! If not, we suggest you head for Google. Very succinctly – the square of the hypotenuse is equal to the sum of the squares of the other sides on a right-handed triangle. The theorem can be used to calculate the distance between two points in 2D space. The same theorem also applies in 3D, we just have one further value to consider (Z).
     
    Wikipedia Entry on Pythagorean Theorem

To support the new enemy code we need a couple of minor tweaks in the base class. Open up volume1/player.pl and modify the ‘move’ method as follows:

    def move(self,boundingBox,gravity=True,checkBounds=True):
        # move forwards - our X/Y is inverted, see the issue
        valid = True
        if self.exploding == False:
            self.player.setFluidX(self.player,-self.speedfactor)
            if (checkBounds == True):
               valid = self.__inBounds(boundingBox)
            if (gravity == True):
               self.player.setFluidZ(self.player,-self.gravityfactor)
        return valid

You’ll note it takes and uses two new parameters – ‘gravity’ and ‘checkBounds’. What is interesting is that on the method declaration we write ‘=True’. This is called a default parameter value. It means the method can be called without providing a value – in this case, if no value is given, it defaults to True. This is useful in this instance because we know there are already calls to the move method elsewhere in our code (to move the main player!). We’re making sure we do not break existing code by the addition of our new value. Of course, if we feel strongly about a new parameter (e.g., the programmer really ought to think about it in every call to the method anywhere in the code), we could leave out the default value and thus force code updates (existing code will fail with an error). If you’re wondering why we’re not boundary checking for the enemy – it is simply because (a) they will never get too far out of bounds if pursuing the player and (b) the AI will still impose the same decision regardless of boundaries and this would cause the enemy to appear ‘stuck’. We’re not applying gravity either because gravity is just an effect we use to make the player concentrate on their height in-game.

Next add a new method to the code:

    def getPos(self):
        return self.player.getPos()

Lastly, we made one further tweak. We observed that both player and enemy were climbing too fast so we updated the calculate method declaration of climb factor to:

        self.climbfactor = self.scalefactor * 0.2

By changing it in the base class both player and enemy respect the change. You may disagree with our change and are actively encouraged to experiment with different values!

Some might also argue that we should have a base ‘character’ class and then derive ‘player’ and ‘enemy’ separately because presently changes to the player class effect the enemy and this might not always be wanted. This again comes down to the ethos of our approach… my game FAST! If we reach a stage where our design is causing us a problem it will not be too much effort to re-factor the player/enemy into a different/more suitable class hierarchy.

All we need to do now is hook all of this into our game. Open up volume1/game.py and first modify startLevel to:

    def startLevel(self):
        if (self.level == 1):
           self.spawnBadGuys(5,1)

All we have done here is increase the number of bad guys – it’s far more fun with 5 as opposed to 2 as it was before. Update spawnBadGuys to:

    def spawnBadGuys(self,howMany,skillLevel):
        for i in range(0,howMany):
            self.baddies.append(EnemyFlanker(self.loader,self.render, \
                                        self.taskMgr,skillLevel))
        # jump start em
        for baddy in self.baddies:
           baddy.teleport(self.world.getSize())

Basically as it was before though we now pass the skill level value (look back above to enemies.py and note we added the value in the constructor too). Finally, in the updateTask method, after the line:

        validMove = self.player.move(self.world.getSize(), True)

…add the following:

        for baddy in self.baddies:
           retval = baddy.update(self.world.getSize(),self.player.getPos())

That’s it! Run the game and you should now find you have some bad guys flying around. You can see exactly what they’re doing if you look at the RADAR. Remember you also have an Enemy Cam accessible via the TAB key.

You’ll note we have not done anything about enemy collisions. Yet. Right now they can fly through the terrain, each other and the player. We will address this in a future issue. Having said that, you probably won’t even notice as things in the game are happening so fast (and the enemies are height matching the player).