January 19, 2011

ISSUE 6: Know your Enemy

Time to add the bad guys! Along the way we’ll be doing a bit of audio editing, some image editing and some more neat tricks with inheritance. To finish, we’ll add some new cameras you can switch through while playing using the ‘tab’ key and also get the RADAR on the HUD working. First, the audio editing. In Issue 5 we provided an engine sound you could download for the game. Time to explain how we made it…

1. Some basic Audio Editing

Creating the engine sound from Issue 5 is nice and quick. We need an engine sound that can be looped continuously in our game. The engine sound, in game, is increased or decreased in volume depending on the player’s speed. To create it we used a piece of software called Audacity – The Free, Cross-Platform Sound Editor. As with all of our software suggestions it is free and will run on Linux, Windows and Mac:

Download Audacity from the Official Site

Now, run Audacity and perform the following steps:

  1. In the top menu, click Generate and then Noise.
  2. On the pop up that appears, set the noise type to Brown and the duration to 000.005 (5 Seconds). Leave amplitude as it is. Click Ok.
  3. You now have a sound, try playing it! Bit tinny for an engine though. So hit Effect on the menu and then Echo. Leave the options as their defaults. Click Ok.
  4. A little better! Now, click Effect again and select Bass Boost. Click Ok to the default options.
  5. Save the file. Then click File -> Export. Select Microsoft WAV File Format and save the sound file. You’ll recall from Issue 5 it’s the WAV we’re after.

That’s it! Try playing the sound in Audacity. To hear it looping click Transport then Loop Play (or hit Shift-Space as a nice shortcut). We chose brown noise because, from the options in Audacity, it is the noise type which is most muffled and lowest pitched – exactly what we needed!

Audacity Sound Editor

2. One Enemy Fast!

Perhaps the quickest way of creating an enemy, at this point, is to use the existing player model but change the texture (image) so it does not look the same. We may, in the future, decide the enemy needs a different model. We may introduce further enemies with further different models. For now, however, it is sufficient to take the player model and ‘make it look different’. If you’re objectionable to this approach consider what we’re doing to be a “place holder”. Changing the actual model is no more difficult than changing the models texture – assuming you have a second model to use.

For the Brave:
Pondering where the model originally came from? The process was as follows:

We found the model online for free here: Turbosquid Model Repository. The model was provided as an LWO (Lightwave Object) file. Now, Panda3D can already read LWO files but we went the extra mile of converting it to an EGG file for you back in Issue 2. Knowing you can get a model to the EGG format is often important – particularly if you plan on editing the model in Blender (see below).

We imported the LWO model into Blender, a free fully functional cross-platform 3D editing suite. You don’t need any models to start in Blender – you can create your own masterpiece from scratch! Visit the Blender Homepage.

It is at this stage you would edit your model if needed. Before finally exporting it as an EGG file by an exporter for Blender (see Converting from Blender in the official Panda3D documentation).

Blender is something for discussion in the future and Blender is not easy! Only the brave should attempt a model change at this stage. 🙂 If you’re sticking with us and the current model, here is what you need to do:

  1. Open up volume1/models/alliedflanker.jpg in GIMP (we introduced the GNU Image Manipulation Program in Issue 2).
  2. On the top menu, click Colors then Invert. Job done!
  3. You might also try the “Colorise” tool, also available from the Colors menu.
  4. If you are feeling adventurous, try changing the USA flag on the wings in GIMP.
  5. Save the modified image as volume1/models/enemyflanker.jpg.

The image below shows the original texture (allied) and our new enemy version:

Alternate Texture

3. Coding with the Enemy

Create a new file in your game directory volume1/enemies.py, contents as follows:

from .player import AlliedFlanker
import random

class EnemyFlanker(AlliedFlanker):
    def __init__(self,loader,scenegraph,taskMgr):
        # 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)

    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)

For the most part, that is it! Look at the code above and notice it inherits from the AlliedFlanker class we already wrote. It then calls the parent constructor and then “does a bit extra” (i.e., introduces code specific to the enemy). Once again we see the power of object oriented programming. Why reinvent the wheel? We’ve already coded a functional flying aircraft, why not reuse it? You’ll notice also the constructor swaps out the texture for our new enemy version (the “,1” at the end of the setTexture call tells Panda3D we are replacing the texture). Beyond that, all we have added is a teleport routine to position the enemy randomly on the terrain (but always making sure it is above the terrains highest point; not actually that important as you’ll see in a future Issue that we simply won’t allow the enemy to collide with the terrain!). “randint” is provided via “random” (hence the import) and generates a psuedo-random number between two values (we say psuedo because if you drill down a little in Computer Science you will learn that there isn’t actually such a thing as ‘random’!). As an example, randint(0,5) would return a value of 0, 1, 2, 3 or 4. Note, not 5!

Turns out our enemies aren’t all that different from ourselves (political?).

All of the other changes are in volume1/game.py. First, add a new import:

from .enemies import EnemyFlanker

Now, add the following to the constructor:

        self.level = 1; self.score = 0; self.baddies = list()
        self.startLevel()
        self.enemyCam = False; self.currentEnemy = 0;

We set a level of 1 for the game, a player score of zero. We declare a list to hold baddies – initially empty. We call startLevel, a new method and we also set an enemyCam and currentEnemy variables. The last two will not make sense until the next section. Notice we have also done something new here – you can have multiple ‘lines’ of Python on one line if you separate the statements by semi-colons. Useful sometimes. Now add the startLevel method, the code being:

    def startLevel(self):
        if (self.level == 1):
           self.spawnBadGuys(2,1) # two level 1 baddies please
        # else - other levels, perhaps splash screen/menu injects here

Which, as you probably guessed, requires the addition of a spawnBadGuys method:

    def spawnBadGuys(self,howMany,skillLevel):
        # note we're not doing anything with skill level yet, as there's only 1 level!
        for i in range(0,howMany):
            self.baddies.append(EnemyFlanker(self.loader,self.render,self.taskMgr))
        # jump start em
        for baddy in self.baddies:
           baddy.teleport(self.world.getSize())

If you follow the code you will note that startLevel checks what level the game is on. Based on that, it spawns bad guys. Bad guys are created and then teleported to a random position (within world boundaries). If you run the game there are now two enemies. They don’t move yet and will simply hover where positioned. If you’re having trouble finding them (very likely as they teleport to random position) you can try:

  • Enabling the debug mode we build in previously.
  • Commenting out the teleport. The enemies will sit where the player starts! Fly out a bit and turn back.
  • Add some camera switching (see “By the power of…” below).
  • Get the RADAR on the HUD working (see the next page of this Issue!

4. By the power of Inheritance!

Ok, shockingly bad He-Man reference. 🙂 We digress. Recall back in Issue 5 we added a ‘lookAtMe’ method to the player? Well, the enemy has inherited this too! So with a bit of code tweaking, we should be able to build in, quite easily, a camera toggle. We’re going to let the player use the tab key on the keyboard to cycle through camera positions – including their own and the ‘lookAtMe’ position for each enemy (the so called ‘enemy camera’).

Update your keyboard setup in game.py to look like this:

    def keyboardSetup(self):
        self.keyMap = {"left":0, "right":0, "climb":0, "fall":0, \
                        "accelerate":0, "decelerate":0, "fire":0, "cam":0}
        self.accept("escape", sys.exit)
        self.accept("a", self.setKey, ["accelerate",1])
        self.accept("a-up", self.setKey, ["accelerate",0])
        self.accept("z", self.setKey, ["decelerate",1])
        self.accept("z-up", self.setKey, ["decelerate",0])
        self.accept("arrow_left", self.setKey, ["left",1])
        self.accept("arrow_left-up", self.setKey, ["left",0])
        self.accept("arrow_right", self.setKey, ["right",1])
        self.accept("arrow_right-up", self.setKey, ["right",0])
        self.accept("arrow_down", self.setKey, ["climb",1])
        self.accept("arrow_down-up", self.setKey, ["climb",0])
        self.accept("arrow_up", self.setKey, ["fall",1])
        self.accept("arrow_up-up", self.setKey, ["fall",0])
        self.accept("space", self.setKey, ["fire",1])
        self.accept("space-up", self.setKey, ["fire",0])
        self.accept("tab", self.setKey, ["cam",1])
        self.accept("tab-up", self.setKey, ["cam",0])
        base.disableMouse() # or updateCamera will fail!

The changes are on lines 3 and 19-20 above. All we have done is add support for capturing the TAB key on the keyboard being pressed. Now, at the end of your actionInput method, add:

        if (self.keyMap["cam"]!=0):
            if (self.enemyCam == False):
                self.enemyCam = True
                self.currentEnemy = 0
            else:
                # "drops off" see below
                self.currentEnemy = self.currentEnemy + 1
            # stop uber fast switching, have to key again
            self.keyMap["cam"] = 0

Remember, we want the TAB key to cycle from the players view through each of the enemies view. So, if we’re currently in enemy cam mode, switch to the next enemy (increment currentEnemy). There’s a danger we’ll run out of enemies and need to go back to the player cam, this is handled in updateCamera (see below). If we’re not already in enemy camera mode, switch to it and set currentEnemy to zero (the first enemy in our list of baddies). Notice we do one final thing – if the TAB key has been pressed we deal with it but then set the value of our keyMap to indicate it is not pressed. This is simply to stop rapid camera switching when you hold the TAB key down. Instead, you must repeatedly press TAB to cycle in the game. Finally, update your updateCamera method to check what viewpoint to show:

    def updateCamera(self):
        if self.enemyCam == True:
           if len(self.baddies) > self.currentEnemy:
               self.baddies[self.currentEnemy].lookAtMe(self.camera)
           else:
               self.enemyCam = False
        else:
            self.player.lookAtMe(self.camera)

Run the game and you should now find you can switch views via the pressing the tab key. Result!

5. Understanding our HUD

Take a look back now at the HUD code we added in Issue 5:

from direct.gui.DirectGui import DirectFrame, OnscreenImage
from pandac.PandaModules import TransparencyAttrib

class GameGUI():
    def __init__(self,render2d):
        self.render2d = render2d

        # image scale of 1 fills screen, position defaults to central
        Scale = 1.0/2.5 # decimal point is VITAL
        radar = OnscreenImage(image='volume1/gfx/radar.png', scale=Scale, \
                                         parent=self.render2d, pos=(-0.95,0,-0.95))
        radar.setTransparency(TransparencyAttrib.MAlpha)
        # note the image itself and how it is centered

        hud = OnscreenImage(image='volume1/gfx/hud.png', scale=1, \
                                      parent=self.render2d, pos=(0,0,0))
        hud.setTransparency(TransparencyAttrib.MAlpha)

It’s generally straight forward. It loads and places two images on the screen – the RADAR and the main HUD information area. What is different (and often confusing to new comers) is the scaling and positioning.

Thus far in MGF we have only dealt with 3D objects and 3D positioning. Things change a little in the 2D world. First of all, coordinates are given via X and Z values. Y means nothing. The values are also in a much smaller range. It is important to understand that if you place an image at a position of 0,0 and a scale of 1 it will fill the whole screen and be exactly centered on the screen. Once you get used to this idea life becomes a lot simpler. The diagram below aims to clarify what we are describing:

2D Positioning

6. Making the RADAR function

Our aim is to represent the whole scene on the RADAR area of the HUD. The tricky part is having the player always central and everything else positioned relative to this. We also want to capture the players heading in the RADAR to complete its functionality. Update your GUI code to read as follows:

from direct.gui.DirectGui import OnscreenImage
from pandac.PandaModules import TransparencyAttrib

class GameGUI():
    def __init__(self,render2d):
        self.render2d = render2d

        # image scale of 1 fills screen, position defaults to central
        self.radar = OnscreenImage(image='volume1/gfx/radar.png', scale=(1.0/2.5), \
                                   parent=self.render2d, pos=(-0.95,0,-0.95))
        self.radar.setTransparency(TransparencyAttrib.MAlpha)

        hud = OnscreenImage(image='volume1/gfx/hud.png',scale=1,parent=self.render2d, \
                            pos=(0,0,0))
        hud.setTransparency(TransparencyAttrib.MAlpha)
        self.dots = list()
        self.playerobj = OnscreenImage(image='volume1/gfx/playerdot.png', \
                                       scale=1.0/20.0,parent=self.radar)
        self.playerobj.setTransparency(TransparencyAttrib.MAlpha)

    def update(self, centerObject, objectList, boundingBox):
        boundingBox = boundingBox * 2
        offsetX = 0.0
        offsetZ = 0.0

        # would be fine for minimap
        self.playerobj.setX(centerObject.getX()/boundingBox)
        self.playerobj.setZ(centerObject.getY()/boundingBox)

        # player center
        if (self.playerobj.getX() > 0.5):
            offsetX = -(self.playerobj.getX()-0.5)
        elif (self.playerobj.getX() < 0.5):
            offsetX = 0.5-self.playerobj.getX()
        # else stays zero
        if (self.playerobj.getZ() > 0.5):
            offsetZ = -(self.playerobj.getZ()-0.5)
        elif (self.playerobj.getZ() < 0.5):
            offsetZ = 0.5-self.playerobj.getZ()

        self.playerobj.setX(self.playerobj.getX()+offsetX)
        self.playerobj.setZ(self.playerobj.getZ()+offsetZ)
           
        for dot in self.dots:
            dot.removeNode() # correct way to remove from scene graph
        del self.dots[:]
        self.playerobj.setR(-centerObject.getH()-90)

        for obj in objectList:
            newobj = OnscreenImage(image='volume1/gfx/reddot.png',scale=1.0/60.0, \
                                   parent=self.radar)
            newobj.setTransparency(TransparencyAttrib.MAlpha)
            newobj.setX(obj.getX()/boundingBox)
            newobj.setZ(obj.getY()/boundingBox)
            newobj.setX(newobj.getX()+offsetX)
            newobj.setZ(newobj.getZ()+offsetZ)
            self.dots.append(newobj) # so can destroy, see call above

For the code to work, you will need the two images referenced. They can be downloaded below. Place them in your ‘gfx’ sub-directory:

You will also need to call the new update method in the updateTask method of ArcadeFlightGame (in game.py):

        self.gui.update(self.player, self.baddies, self.world.getSize())

Arguably, we have could added the GUI update to the task manager instead, the same way we added updateTask back in Issue 3. This is a decision you can make for yourself – lean on/rely on the task manager or, as we have done here, add only one “update” method to the task manager and call other needed methods from it. It’s really a preference decision and nothing much else. Finally, add the following methods to the AlliedFlanker class in player.py:

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

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

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

Note they will be available to the EnemyFlanker class too via inheritance. Run the game again and you should find you have a fully functional RADAR making is much easier to locate the enemy! The enemy still appears quite small and can be hard to spot. We updated our enemy constructor with a call to:

        self.player.setScale(0.5)

This makes the enemy larger and a little easier to see! As you fly around, you will see the player icon rotate to indicate heading while red dots will indicate enemy positions. You should now find it quite easy to locate the enemy on your map! Remember, the enemies are positioned at a height of 100 (the same as the player’s start height). The image below shows the RADAR in action:

RADAR

How does it work? The magic is in the update method. The code achieves the following:

  1. Double the bounding box value. The value details the world size (1024) and we need to scale everything on the RADAR accordingly. However, we plan to have the player centred on the RADAR. Thus, the maximum distance an enemy could be from the player at any point in the game is 1024. This means the distance from the RADAR centre to its edge should (i.e., its radius) should be able to capture a whole worlds width (1024) making the total RADAR width (diameter) equal to double the bounding box.
  2. Create some offset values, initially zero.
  3. When we created the player dot in the constructor we set the parent to self.radar and, because of this, positioning the dots on the RADAR now operates on coordinates between 0,0 (bottom left) and 1,1 (top right). Our code positions the player dot on the RADAR based on its position. If we were adding a mini-map/overhead map we would be done now. We are not done because we want the player to always be central on the RADAR. So why did we bother at all (as opposed to simply positioning the player dot to 0.5,0.5 in the constructor)? Read on!
  4. We go on to calculate the X,Z movement required to re-centre the player. This might seem pointless but it is not. Calculating what needs to be done to correct the players position gives us two offset values. We can use these same values thereafter to correctly adjust the position of the enemy (red) dots keeping the whole RADAR correct relative to the player sat in the middle.
  5. Move the player via the offsets.
  6. Delete every entry in our list of “dots”. Every time we perform a RADAR update an enemy may have moved, the player may have moved, enemies might have been created or destroyed and so we’re not even certain how many there are. We do, however, always need to maintain a list of the “last RADAR positions” so we can clear/wipe the RADAR. Failure to do this would result in “trails” on the RADAR.
  7. Update the heading of the players icon/dot. This captures the players heading on the screen.
  8. Go through the list of bad guys. For each, create a red dot. Position the red dot. Adjust via the calculated offsets. Store the dot in the list of dots so we can delete it on the next pass.

Not a bad little RADAR for such a short amount of code!

7. Some final Tweaks

You have seen several times now how software is iterative and ‘things change’. With the bad guys/RADAR on the screen we’re beginning to get a feel for how the game might play. Based on that, we made a few more tweaks to our code to make it feel like a smoother experience.

An update to ‘calculate’ in player.py:

    def calculate(self):
        """ Should be called every frame. 
        It calculates how the player should move (position and orientation) """
        self.factor = globalClock.getDt()
        self.scalefactor = self.factor *self.speed
        self.climbfactor = self.scalefactor * 0.3
        self.bankfactor  = self.scalefactor * 0.5
        self.speedfactor = self.scalefactor * 2.9
        self.gravityfactor = ((self.maxspeed-self.speed)/100.0)*0.75 #1.35

Updates to bank left/right in player.py:

    def bankLeft(self):
        if (self.speed > 0):
            self.player.setH(self.player.getH()+ (2*self.bankfactor))
            self.player.setP(self.player.getP()+self.bankfactor)
            # quickest return:
            if (self.player.getP() >= 180):
                self.player.setP(-180)

    def bankRight(self):
        if (self.speed > 0):
            self.player.setH(self.player.getH()- (2*self.bankfactor))
            self.player.setP(self.player.getP()-self.bankfactor)
            if (self.player.getP() <= -180):
                self.player.setP(180)

8. Wrap up and Summary

Another productive Issue! We have successfully added the bad guys to the scene while reusing as much code as possible, added camera toggling, created a fully functional RADAR and done some final tidying and tweaking. Join us in Issue 7 where we will tackle the problem of end-users (players) operating in different screen resolutions. There’s much more, of course, but we always like to end with a small teaser as we’re sure you noticed! (oh OK, hint: think…. Artificial Intelligence :-P)

Goto Issue 7 >>>