All Pages

ISSUE 4: What goes up…

Add collisions.  Add gravity. Add explosions. Add water. Add camera tricks. Improve fog, lighting, sky. Issue 4, all done!

Issue 4 already? Time flies! In this one we’re going to do a bit of housekeeping. As a project grows you often need to refactor (rewrite) parts of your code to keep the code scalable and readable. Our approach is what you might consider ‘rapid application development’ (RAD). An alternative approach would have been to sit down and work out the whole game in advance – before writing any code – but the MGF aim is “results, fast” so the more traditional ‘waterfall’ approach is out the window! We want fun while we learn. You’ll also be adding collisions to your game, explosions, gravity, water effects and a bit of camera trickery. As promised in Issue 3 we will also explain and improve the fog, lighting and sky.

1. Imports and Modules

Unlike last time, where we gave you the whole code in advance, we are opting this time to make changes to the code one at a time. Don’t worry – we will give the full code listing at the end of the issue! To begin with, update the start of your code, your ‘imports’ to look like this instead:

from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from direct.interval.LerpInterval import LerpTexOffsetInterval, LerpPosInterval
from pandac.PandaModules import CompassEffect, CollisionTraverser, CollisionNode
from pandac.PandaModules import CollisionSphere, CollisionHandlerQueue, Material
from pandac.PandaModules import VBase4, VBase3, TransparencyAttrib
from panda3d.core import AmbientLight, DirectionalLight, Vec4, Vec3, Fog
from panda3d.core import BitMask32, Texture, TextNode, TextureStage
from panda3d.core import NodePath, PandaNode
from direct.gui.OnscreenText import OnscreenText
import sys

Quite a few additions, but all will be used by the end of the issue! So what exactly are these imports doing? In Python, there a number of ways to import what you need. So let’s start with the simplest. Open up a text editor and enter the following code:

class MGFMaths():
    def __init__(self):
        # nothing to do
        pass

    def square(self,number):
        return number*number;

    def cube(self,number):
        return number*number*number

Save the file in a directory somewhere (not the same one as your game, as this is illustrative only). Save it as ‘mgfutil.py’. The file name is important! Now, in the same directory, create a ‘test.py’ file containing the following:

import mgfutil

myMath = mgfutil.MGFMaths()
print "Square of 10 is: "+str(myMath.square(10))
print "Cube of 10 is: "+str(myMath.cube(10))

Save it. Now run it:

python test.py

It should run and complete without any errors or warnings. Hopefully this example helps clarify. The “import mgfutil” caused the Python interpreter* to look for mgfutil.py and load it. It’s that simple. In our game, you see examples using the dot notation (e.g. import direct.showbase.ShowBase). The dots are used to give directory paths. If you had saved mgfutil.py in a sub-directory called “libs” you would have to “import libs.mgfutil”.

The code you saved in mgfutil.py is considered in Python to be a ‘module’. A module is simply a collection of Python definitions and statements saved in a ‘.py’ file.

* Interpreters and Compilers: The Python Interpreter is a computer program, it is the python command you execute from the command line. Its job is to execute the instructions it is given (your code!). Try running Python without specifying a .py file – you will get an interactive prompt where you can enter (and execute) Python code one line at a time! The alternative to an interpreted language such as Python is a compiled language. With compiled languages your code is translated to machine code the computer can directly understand (e.g., an EXE file on Microsoft Windows). This is done via a compiler.

Which is best?

It depends. Compiled languages tend to be faster but platform specific. Interpreted languages tend to be cross platform. Take the Panda3D engine for instance. It is written in a compiled language for speed (C++ in this instance). That’s why, in Issue 1, you had to download the Panda3D version for your operating system. Writing applications using Panda3D is done using Python as we know (though it can also be done in C++). Your Python code will work just fine on Windows, Linux, Mac and more.

There is a third option that is essentially ‘somewhere inbetween’. Java is an example of such a language. The code is compiled to a platform independent ‘bytecode’ instead of machine code. This is then executed via the Java Virtual Machine (JVM). The JVM is the platform specific portion that translates the bytecode to machine code.

But wait – we have all manner of includes in our game code, but not that much in our game directory, so what’s going on? Python uses a path – a set of directories to search in order to find anything you might import. If you enter the code below in a new file, save it as showpath.py, and execute it, it will print your path out:

import sys
print sys.path

The Panda3D installation you did in Issue 1 will have made sure the engine libraries (modules!) are in the Python path. For example, on our install under Linux, the file ShowBase file is here:

/usr/local/share/panda3d/direct/showbase/ShowBase.py

Ok, a more complex example. Notice in your test code that to create an MGFMaths object you had to write:

myMath = mgfutil.MGFMaths()

In Python, and many other languages, a concept exists called the ‘namespace’. Think of it as dividing up code into named blocks! To access MGFMaths we have to grab it from the mgfutil namespace. The code that is in mgfutil is NOT by default accessible in your main program. It is in a different namespace. The concept is important – particularly in large applications using many libraries. What would happen if there was no namespace and you imported two modules – both of which provided an MGFMaths class? Unlikely given what we named our class, but that’s because we like to think about ‘good naming’ when we code. 🙂

This is fine. But suppose now that MGFMaths provided lots of different functionality – 10s of classes. Some we need, some we don’t. Our code would be littered all over the place with “mgfutil.”. Every time we use anything from the mgfutil module we have to prefix the code.

Now update your test.py file to look like this:

from mgfutil import MGFMaths

myMath = MGFMaths()
print "Square of 10 is: "+str(myMath.square(10))
print "Cube of 10 is: "+str(myMath.cube(10))

Notice this time the “from” syntax. This is saying “from the mgfutil module import the MGFMaths class into our namespace”. Hence, later in the code, you note we did not have to prefix creating the object (myMath = MGFMaths).

….and that’s what you are seeing in many of our game import statements. Notice later in your game code that the AmbientLight object is simply created (in your createEnvironment method) – no prefixes – because we used a from/import at the start of our code.

Don’t worry too much about this next one for now, but you can also assign an import to a variable:

myMod = __import__('mgfutil.MGFMaths')

There is one more import approach which we’ll cover for completeness but strongly discourage you from using it!

from direct.showbase.ShowBase import *

The asterix means “everything”. In our game we only “import ShowBase” – it’s the only thing we want from the module. Importing “*” would import every all other classes/functions/data the module provided into our current namespace. A bad idea. Remember this: good code comes when we divide and conquer.

2. Collisions, Debugging and the For-Loop

While we are currently able to fly our player around the world, there are no collisions. We can fly straight through any mountain or terrain. This would be fine if we were writing Ghost Busters! But, well, we’re not. 🙂 Our player needs to be able to crash (and burn, and die).

First, however, open up your code and somewhere near the start of your constructor, add the following lines (in our code, we added it right after the call to the ShowBase constuctor):

        self.debug = True
        self.statusLabel = self.makeStatusLabel(0) 
        self.collisionLabel = self.makeStatusLabel(1) 

The ‘makeStatusLabel’ method is new and does not exist. Below your constuctor, define it as follows:

    def makeStatusLabel(self, i): 
        return OnscreenText(style=2, fg=(.5,1,.5,1), pos=(-1.3,0.92-(.08 * i)), \
		align=TextNode.ALeft, scale = .08, mayChange = 1) 

We’re not going to discuss this method in detail, but you can read about the OnscreenText routine over here. The backslash you will recall we used in the Issue 3 code too. It simply helps layout/display by spreading one line of code over several.

What we are doing here is adding a ‘debug mode’ into the game. Essentially, whenever you want to ‘run in debug mode’ just change self.debug value to be ‘True’. When you wish to return to normal (or ‘release’) mode just change it back to ‘False’.

Next, at the end of your constructor, after the call to createEnvironment add:

        self.setupCollisions() 
        self.textCounter = 0 

Elsewhere in your code, define the setupCollisions method as follows:

    def setupCollisions(self): 
        self.collTrav = CollisionTraverser() 

        self.playerGroundSphere = CollisionSphere(0,1.5,-1.5,1.5) 
        self.playerGroundCol = CollisionNode('playerSphere') 
        self.playerGroundCol.addSolid(self.playerGroundSphere) 

        # bitmasks 
        self.playerGroundCol.setFromCollideMask(BitMask32.bit(0)) 
        self.playerGroundCol.setIntoCollideMask(BitMask32.allOff()) 
        self.world.setCollideMask(BitMask32.bit(0)) 

        # and done 
        self.playerGroundColNp = self.player.attachNewNode(self.playerGroundCol) 
        self.playerGroundHandler = CollisionHandlerQueue() 
        self.collTrav.addCollider(self.playerGroundColNp, self.playerGroundHandler) 

        # DEBUG 
        if (self.debug == True): 
            self.playerGroundColNp.show() 
            self.collTrav.showCollisions(self.render) 

Notice the checks against our new debug variable. The collision code might look complicated and we’d have to agree that collisions in Panda3D are not immediately intuitive. It amounts to:

  1. Create a collision traverser. A traverser does the actual work of checking for collisions. It maintains a list of active world objects. When writing your code, it is best to think in terms of ‘from objects’ (or ‘colliders’) and ‘into objects’ (or ‘collide-able’). In our game, the ‘from object’ is the player while the ‘into object’ is the terrain.
  2. Create a collision sphere. The sphere is a kind of Collision Solid. A collision solid is an invisible object created purely for testing collisions. What we need to do is create a sphere and attach it to the player. Whenever that sphere intersects with the terrain, we have a collision (crash). The parameters we passed were 3 centering values (cx,cy,cz) that detail how the sphere will be positioned relative to the player (when we attach it to the player below) and a fourth value for the size of the sphere.
  3. Create a collision node. A collision node can hold any number of collision solids.
  4. Add the sphere to that newly created node. Our collision solid (sphere) has to be held by a collision node.
  5. Setup our bitmasks. Bitmasks are a common source of confusion. A ‘mask’ is used in what we call a bitwise operation. A BitMask32 as used in our code indicates a 32-bit number. Deep down in the heart of your CPU everything is in binary. Binary numbers (or ‘base-2’) use only zeros and ones (or ‘off’ and ‘on’). For example, the number 201202 in binary reads:
    0000 0000 0000 0011 0001 0001 1111 0010
    We are not, at this stage, going to discuss the mechanics of the binary number system but those interested can read more here. It is very likely we will return to this and explain in more depth in a future Issue. What we do need to understand though is that a 32-bit number has 32 binary digits each being zero or one. When we set bit ‘masks’ we are manipulating the digits (known as ‘bits’). For example:

    BitMask32.allOff()
    = 0000 0000 0000 0000 0000 0000 0000 0000
    
    BitMask32.bit(0)
    = 0000 0000 0000 0000 0000 0000 0000 0001
    
    BitMask32.bit(1)
    = 0000 0000 0000 0000 0000 0000 0000 0010
    
    BitMask32.bit(2)
    = 0000 0000 0000 0000 0000 0000 0000 0100 
    

    Each object in Panda3D has two masks – the FROM and the INTO as we discussed above (this can also be considered as ‘source’ and ‘destination’). Objects will not be considered for collision if the masks do not match. It is because of this we set our player and world bitmasks the same (bit zero, the first digit of our binary number reading right to left). In other words, the player and the world can collide!

    setCollideMask is used to set the INTO collision mask on the world (which subsequently sets the mask on all of its child nodes – node paths are discussed later in this Issue). We can also be more granular than this by applying the collision mask on the actual collision node using setIntoCollideMask and setFromCollideMask. Our code illustrates both mechanisms.

    In short, we use bit masks to filter our collisions. This will be increasingly important as we add more objects to our world in future issues.

  6. Attach the collision node to the player. Where the player goes, the collision sphere goes. When we call ‘attachNewNode’ a node path is returned and stored in the variable ‘playerGroundColNp’ (again, node paths are discussed later in this Issue).
  7. Create a collision handler queue for the newly created node. Collisions are stored in queues. There can, on occasion, be many many collisions. You don’t want to miss any, so we put them in a queue to be processed later in our code.
  8. Add that queue to the traverser. We add the new queue and a reference to collision node. When it comes time for the traverser to check for collisions, it now knows to check our new collision node (player into terrain) and if there’s a result – store it in our collision queue.

You can, as always, add the new method wherever makes the code feel ‘easier’/more maintainable to you. There are ‘conventions’ and approaches to this. In Industry/commercially, developers quite often follow a ‘style guide’ that dictates the order methods should be in so everyone is ‘one the same page’ (likewise with where variable declarations go etc.).

Collision and OOBE

Finally, we need to check for collisions in our updateTask method. Change your code to look like this:

    def updateTask(self, task): 
        self.updatePlayer() 
        self.updateCamera() 

        self.collTrav.traverse(self.render) 
        for i in range(self.playerGroundHandler.getNumEntries()): 
            entry = self.playerGroundHandler.getEntry(i) 
            if (self.debug == True): 
                self.collisionLabel.setText("DEAD:"+str(globalClock.getFrameTime())) 
            # we will later deal with 'what to do' when the player dies 

Our collision solid (sphere) has to be held by a collision node. We call the traverser to find collisions. Any collisions will be in our queue. We look at the queue and process each entry in it. To do this we are using a ‘loop’. Loops are very common in programming and there are several types. We are using a ‘for-loop’.
– A loop is used to to make a section of code repeat a number of times.
– A ‘for loop’ allows us to repeat a section of code a number of times with different values.

In this case, we declare a variable called “i”. Because “i” is declared as part of the loop it only exists while the loop is executing. The variable does not exist outside of the loop. You might recall our discussion of local variables in Issue 3. This is similar only even more restrictive. Where variables do/do not exist is something we call ‘scope’. Scope is important as it avoids unwanted interactions (code elsewhere in our program might set the value of “i” to something unwanted).

The value of “i” is set to “range( self. playerGroundHandler. getNumEntries() )”. Range is a function in the Python language that generates lists of arithmetic progressions. We tell it our range is “getNumEntries”; That is, however many entries are in the queue. If there were 3 collisions in the queue range would generate the list “0,1,2”. Notice we start counting at zero! This is another very common thing you will run into with programming! The code below (and indented further than) the ‘for i….’ line would be executed 3 times. Once with “i” set to zero, once with i=1 and one final time with i=2.

For each iteration of the loop we call getEntry – this retrieves the collision event from the queue. We pass “i” to getEntry. Taking our example of 3 collisions again, getEntry would get called 3 times, with the values 0, 1 and finally 2. We are specifying exactly which collision event we want to pull from the queue. Again, notice we count from zero!. In our code, we don’t actually do anything with our ‘entry’, it’s enough for us to know a collision has happened. In future issues we will look more closely at what information you can retrieve about a collision.

Now, if you run your game in debug mode and fly into the terrain, you should see a message appear at the top-left of your screen saying the player is dead (along with the current time). Turn off the debug mode and you will see nothing displayed on the screen. This is how we want things to be – we will return to “what to do when the player is dead” shortly. For now, we have just added an on screen indication (that only appears in debug mode, because it should not be present in the final game!).

Also, while in debug mode, you should see a visual indication of the collision happening and you should also be able to see the collision sphere itself around the player.

TIP 1: If collisions are happening before the player hits the ground the likely cause is the size of your collision sphere. Enable the game debug mode (which is turn calls ‘showCollisions’) to view the sphere and adjust its size in code until it works correctly.

TIP 2: If collisions are happening and not being caught (e.g., flying through the ground), see the discussion in Issue 5 regarding the use of ‘Fluid’ movements and “respect previous transform” on the collision traverser.

3. Explosions

What happens when the player crashes? Ultimately, we’ll be looking at a system of ‘lives’ leading to the classic ‘game over’. Prior to that, we need to make it so that when the player dies we display an explosion and then take a ‘reset’ action. Add the following method to your code:

    def resetPlayer(self): 
        self.player.show() 
        self.player.setPos(self.world,self.startPos) 
        self.player.setHpr(self.world,self.startHpr) 
        self.speed = self.maxspeed/2 

Now, we have lines similar to this in our constructor already. Good code does not duplicate, it reuses. So, find the corresponding lines in your constructor that set the position, orientation and speed and remove them. Then, in their place, add a call to our new method. You will also need to define ‘startPos’ and ‘startHpr as they are used in the method above. The whole ‘block’ in our constructor now looks like this:

        self.maxspeed = 100.0 
        self.startPos = Vec3(200,200,65) 
        self.startHpr = Vec3(225,0,0) 
        self.player = self.loader.loadModel("alliedflanker.egg") 
        self.player.setScale(.2,.2,.2) 
        self.player.reparentTo(self.render) 
        self.resetPlayer() 

We’ll show you our full code at the end of the Issue in case you have problems or simply wish to compare. Notice also that in our method we’ve added a bit of speed at the start. Rather than the game starting with a stationary player we are opting to start the game with the player flying at a moderate speed. The “Vec3” is new. A Vec3 is a 3-component vector. Both setPos and setHpr will accept a Vec3 as a parameter as you can see in the resetPlayer method.

Run your game and crash into the terrain. You should find the game ‘starts again’.

Next, we need an explosion. We’re opting for a simple and easy trick for this one. We are going to use a model that is a small square. The model will be textured by a ‘red ring’ though you can edit the image to be whatever you like! When the player ‘explodes’ we will hide the player model, display the red ring in its place, and over a number of frames scale the red ring to be larger and larger giving an exploding effect. The model is about as simple as a model can be. In fact, it’s that simple that in Panda3D we could create it in a few lines of code and we’ll discuss that possibility in the future. In this Issue, however, we instead giving the files for download. Download the model and the image below and place them in your game directory:

(your may need to right-click and save-as depending on your browser/operating system)

In your constructor, load the explosion ring as follows:

        # load the explosion ring 
        self.explosionModel = loader.loadModel('explosion') 
        self.explosionModel.reparentTo(self.render) 
        self.explosionModel.setScale(0.0) 
        self.explosionModel.setLightOff() 
        # only one explosion at a time: 
        self.exploding = False

By setting the scale of the ring to zero, it is not visible in the game by default. Did you notice something unusual in the code snippet? When we called loadModel we simply said ‘explosion’ and not ‘explosion.egg’.

Panda3D has a configuration variable for the ‘default model extension’ which is set to .egg. You will see why when we reach the stage of packaging the game for distribution. We have been specifying file extension thus far because it made the code a little easier for new developers to understand. With this in mind:

  • Update every call in your code that references an egg file.
  • Remove the file extension.
  • Run your game again, it should still work perfectly fine!
  • NOTE: this only works for egg files, you will still need the file extension for .bam files!

While we are here, we can tidy up our game directory. Create a sub-directory where your game resides called ‘models’. Move all of your egg files, bam files and their images into this directory. Ours looks like this:

+models
    alliedflanker.egg
    alliedflanker.jpg
    clouds.png
    explosion.egg
    explosion.png
    sky.egg
    square.egg
    water.png
    world.bam

Yours should look the same though you will currently be missing the “square.egg” file. In your game top-level directory you should now only have one file – your .py file. It’s all a lot tidier and easier to work with! You will need to again update your loadModel calls to match (e.g. loadModel(‘models/sky’)). Notice also that there is no height map or colour map image file. When we converted our GeoMipTerrain into a bam file in Issue 2 we removed the need for the image files.

Now to make the explosion happen. Again, some house keeping first. Remove all of the code in your updatePlayer method that related to enforcing world boundaries. Move this code into a new method called applyBoundaries as follows:

    def applyBoundaries(self): 
        if (self.player.getZ() > self.maxdistance): 
            self.player.setZ(self.maxdistance) 
        # should never happen once we add collision, but in case: 
        elif (self.player.getZ() < 0): 
            self.player.setZ(0) 

        # and now the X/Y world boundaries: 
        boundary = False        
        if (self.player.getX() < 0): 
            self.player.setX(0) 
            boundary = True 
        elif (self.player.getX() > self.worldsize): 
            self.player.setX(self.worldsize) 
            boundary = True 
        if (self.player.getY() < 0): 
            self.player.setY(0) 
            boundary = True 
        elif (self.player.getY() > self.worldsize): 
            self.player.setY(self.worldsize) 
            boundary = True 

        # lets not be doing this every frame... 
        if boundary == True and self.textCounter > 30: 
            self.statusLabel.setText("STATUS: MAP END; TURN AROUND") 
        elif self.textCounter > 30: 
            self.statusLabel.setText("STATUS: OK") 

        if self.textCounter > 30: 
            self.textCounter = 0 
        else: 
            self.textCounter = self.textCounter + 1 

It’s mostly the same as the code was when it was in updatePlayer. Though we have added a new section at the end (beginning “lets not be doing this every frame”). The new section simply adds a status indicator on the screen. It will read “OK” or, if the gamer attempts to fly off the end of our world, it will advise them to “TURN AROUND”. The text counter we added previously in our constructor. The purpose is simple. Updating the on screen text every single frame is just not necessary. This is again a ‘performance tuning’ tweak. We have opted to update the on screen text every 30 frames. This is far ‘easier’ for your CPU with little noticeable impact to the player.

Now change your updatePlayer method. It should roughly read as per below (we have omitted the bulk of the code for clarity as it is unchanged):

    def updatePlayer(self): 
        # scale, climb, bank, speed factor 
        # (CODE OMITTED IN SNIPPET)

        # Climb and Fall 
        # (CODE OMITTED IN SNIPPET)

        # Left and Right 
        # (CODE OMITTED IN SNIPPET)

        # throttle control 
       # (CODE OMITTED IN SNIPPET)

        # move forwards - our X/Y is inverted, see the issue 
        if self.exploding == False: 
            self.player.setX(self.player,-speedfactor) 
            self.applyBoundaries() 

It’s the last 3 lines we are interested in. All of our boundary checking has been removed from the updatePlayer method. Instead, we call our new method ‘applyBoundaries’. We also check our ‘exploding’ variable. A boolean variable (boolean = True or False). We do not want the player to move while exploding, hence the check. If the player is not moving, there’s no need to check the boundaries either. Hence both statements are nested in this IF statement.

Return now to your updateTask method and adjust it to look like this:

    def updateTask(self, task): 
        self.updatePlayer() 
        self.updateCamera() 

        self.collTrav.traverse(self.render) 
        for i in range(self.playerGroundHandler.getNumEntries()): 
            entry = self.playerGroundHandler.getEntry(i) 
            if (self.debug == True): 
                self.collisionLabel.setText("DEAD:"+str(globalClock.getFrameTime())) 
            if (self.exploding == False): 
                self.player.setZ(entry.getSurfacePoint(self.render).getZ()+10) 
                self.explosionSequence() 
        return Task.cont 

Essentially, when the player collides and the player is not already exploding, begin exploding. It would look horrible without this check as an explosion would commence for every detected collision. We only want one collision! Notice also we bump the players height a little. The player has collided with the terrain and we want to begin an explosion around the player. But that would result in an explosion ‘within the terrain’ (because that’s where the player is!). So, we simply bump the position height a little so the explosion will be nice and visible.

You will, of course, need the explosionSequence method. Here it is along with a new method it relies on called ‘expandExplosion’:

    def explosionSequence(self): 
        self.exploding = True 
        self.explosionModel.setPosHpr( Vec3(self.player.getX(),self.player.getY(), \ 
                               self.player.getZ()), Vec3( self.player.getH(),0,0)) 
        self.player.hide() 
        taskMgr.add( self.expandExplosion, 'expandExplosion' ) 

    def expandExplosion( self, Task ): 
        # expand the explosion rign each frame until a certain size 
        if self.explosionModel.getScale( ) < VBase3( 60.0, 60.0, 60.0 ): 
            factor = globalClock.getDt() 
            scale = self.explosionModel.getScale() 
            scale = scale + VBase3( factor*40, factor*40, factor*40 ) 
            self.explosionModel.setScale(scale) 
            return Task.cont 
        else: 
            self.explosionModel.setScale(0) 
            self.exploding = False 
            self.resetPlayer() 
            # and it stops the task 

The first thing we do is set exploding to True. As noted previously, 'one explosion at a time please' for the player. We then set the position and orientation of the explosion model to match the player. Next, hide the player (it would look awful strange if there was an explosion and the player model remained on screen unaffected!). You may have been wondering why we added a “show” call in our resetPlayer method, now you know! After this, we begin the explosion sequence by adding a task to the task manager. This should all be quite familiar now based on what we covered in previous issues. The method to handle the update task is expandExplosion.

expandExplosion checks the scale of the explosion model (remember, it begins at zero). If it is less than 60 we scale the explosion up. In other words, we want to draw the explosion growing from a size of zero to 60. 60 is just an arbitrary number we picked that gave good results, feel free to change it to suit your game! If it has reached the maximum scale, we put it back to zero (make the explosion disappear), set exploding to false, and reset the player. Notice that we do not 'return' anything in that scenario, this achieves the result of removing the task from the task manager (because we are done exploding!). In the case where we are still growing the explosion, we return 'Task.cont', thus knowing expandExplosion will get called the next frame.

Run the game, crash the player and you should now see a nice explosion before the player is reset to the start position.

4. An out of body experience!

You may have noticed by now that if you fly high or to edges of your terrain, you see the sky end! It's a problem we'll be fixing soon. To understand what is going on in your game it often helps to use something Panda3D provides called the 'out of body experience' (or 'OOBE'). Basically, in OOBE mode you get the same effect as at the end of Issue 1 – move around your terrain using the standard mouse controls. The difference is the main game camera is still present and can be seen!

Below is a screen shot of OOBE mode – notice the camera! While in OOBE mode you can still fly the player using the keyboard but can also observe the in-game camera following the player around!

OOBE

To enable OOBE add this line in your constructor:

        base.oobe()

When you wish to turn this off, comment out the line with a '#'. You could also add this into your debug mode if you wish by adding the call to an IF statement (if self.debug == True: base.oobe()).

If you run into problems with visibility in OOBE try commenting out the call to setFar in your constructor (you'll recall we limited the camera view for performance purposes in-game, but in-debug you may wish to turn this off).

5. Voodoo Magic!

That was strange wasn't it? We simply called 'base.oobe()'. Not 'self.base.oobe()'. Look back at your createEnvironment method too. Notice we just referred to 'render' and not 'self.render'. Why is that?

We did it on purpose to illustrate something in Panda3D that isn't really good practise. The use of Global Variables. A global variance is one that exists in the global name space. That is, it is available to all classes/code/modules within a project. You can reference a global variable anywhere in your code and it will work.

After our discussions on name spaces and scoping previously we're going to say, quite clearly, global variables are bad and should be avoided. Sadly, we cannot avoid the 'base' global variable. We do however recommend replacing your other global references (render) with their 'self' counterparts.

So why is it there? The answer from one of the Panda3D developers “this was done back when Panda was just an internal tool for a small group of people” and “Panda3D 2.0 won't do this kind of thing”. Consider it confirmed; Globals are bad!

If you still wish to know what other global variables there are head over to our Cheat Sheet section, there's an image dedicated to 'Voodoo Globals'.

6. The camera values from Issue 3

Refer back to your updateCamera method. We defined this in Issue 3 but did not explain to you how we came up with the values. The values are what keeps the camera positioned during the game relative to the player.

Create a new Python (.py) file as follows:

from direct.showbase.ShowBase import ShowBase 
from direct.task import Task 
 
class MyApp(ShowBase): 
 
    def __init__(self): 
        ShowBase.__init__(self) 
        self.world = self.loader.loadModel("models/world.bam") 
        self.world.reparentTo(self.render) 

        self.player = self.loader.loadModel("models/alliedflanker") 
        self.player.setPos(20,20,65) 
        self.player.setH(225) 
        self.player.reparentTo(self.render) 
        self.camera.setPos(self.player, 25.6225, 3.8807, 10.2779) 
        self.camera.lookAt(self.player) 
        self.taskMgr.add(self.updateTask, "update") 

    def updateTask(self,task): 
        # 'str' simple converts the Vec3 to string we can print
        print "POS: "+str(self.camera.getPos(self.player)) 
        print "HPR: "+str(self.camera.getHpr(self.player)) 
        return Task.cont 
 
app = MyApp() 
app.run() 

...and save it in your game directory as 'campositioner.py'. Now execute this new Python program. It will behave identically to what you had at the end of Issue 2. You will have a world, a player on the world and the default camera position and mouse controls. While the game is running look at the command prompt/terminal from which you launched the game and notice it is constantly printing out values – the position of the camera relative to the player. All you now need to do is position the camera how you would like it to be during the game. Once you have done this, the values being printed out in your terminal values are the values you need to put in to your updateCamera method. Simples! 🙂

7. Gravity

Currently if you slow down to a stop in the game you will find your player just hovers. Stationary and levitating. It is time now to add some basic physics to the game – starting with gravity, to pull the player down to the ground. Our gravity needs to be relative to speed too – the faster you fly the more lift you create (it's how planes work!) so the gravity is effectively less. With that in mind...

Panda3D ships with support for several Physics libraries. Firstly, it has its own built in library, comparably basic it provides support for applying forces to objects. Next, there is the Open Dynamic Engine (ODE) which allows for far more complex simulation. Lastly, support for NVIDIA PhysX was added from Panda3D 1.7.0 onwards but is not (as of yet) as well documented.

So which are we to use? For our purposes – an arcade dogfight game – at this stage in the development – the answer is “none of the above”. We would not only need to setup a gravity simulation, but also simulate aircraft lift. If we were writing a simulator we would be recommending to go through all of this, but for a basic arcade game we can achieve the result we want in two lines of code!

(worry not, if you were wondering – yes, we will most certainly be revisiting the Panda3D Physics options in the future!)

At the start of your updatePlayer method, right after the 'factor' variables are created, add a new one:

        gravityfactor = ((self.maxspeed-self.speed)/100.0)*2.0

Feel free to play with the values to see what impact it has on the game. Now, at the end of your updatePlayer method, update the code to look like this:

        if self.exploding == False:
            self.player.setX(self.player,-speedfactor)
            self.applyBoundaries()
            self.player.setZ(self.player,-gravityfactor)

The last line (setZ) is the new line. If you run your game now you will find the player gradually drops to the ground. The slower you fly, the faster you drop. Fly up to top speed and you won't drop at all. That's it! Two lines of code, one slightly more playable game!

8. It's a kind of magic...

Here's a quick change with noticeable impact. Change your updateCamera method to look like this:

    def updateCamera(self):
        # see issue content for how we calculated these:
        percent = (self.speed/self.maxspeed)
        self.camera.setPos(self.player, 19.6225+(10*percent), 3.8807, 10.2779)
        self.camera.setHpr(self.player,94.8996,-12.6549, 1.55508)

Notice we are positioning the camera relative to the players speed. The result? As you fly faster the player model moves further away from the camera. Slow down and the camera gets closer to the player. The screen shots below show this in action, the first is low speed, the second high:

Camera Tricks

9. Lighting, Fog, the Sky and Node Paths

We ended Issue 3 by adding lighting, fog and a sky. We didn't explain how each worked in detail then, but now is the time! We also noted some problems with our sky that we will address now.

As we noted previously, there are 3 main options to consider for a Sky. The sky dome, the sky sphere, the cube map. A dome is exactly that (refer back one page, we showed the sky dome in our discussion of OOBE). A sphere is also as you would expect - a large ball painted with a sky texture on the inside, we place our world (or at least the visible world) within the sphere. A cube is similar but uses 6 images instead of one (one for each face of the cube), quite useful for more varying skies or scenarios where you want a visually different floor/ceiling.

Types of Game Sky

In Issue 3 we opted for the sky dome. You have already seen problems with it if you fly high or near to the end of your world. The sky dome is the easiest solution but is just not suited to a flight game. Had we been working on a platform game or any other game where the player is typically ground-based it would likely have been fine. To seal up the gaps in our world are going to switch to a sky sphere. Welcome to the world of trial and error! 🙂

The Panda3D web site offers a free 'art pack'. It contains hundreds of models of varying quality. Sometimes, you'll find the 'perfect model' in this pack. Sometimes, you might find something that is perfect as a 'place holder' to be replaced later. Luckily for us, it includes a nice sky sphere that pretty much 'just works' for our game.

You can download the entire Art Pack by clicking here. You need to then extract the 'alice skies blue skysphere' model in the 'cat-skies' folder (directory).

If you just want the actual sky sphere, you will need the model (egg) file and the texture (image) file. You can download them using the links below:

Save the files in your models directory. You can delete the old sky if you like or archive it for future use and/or reference. Changing the sky changes the environment. That also results in us needing to tweak our fog and lighting. Update your createEnvironment method to look as follows:

    def createEnvironment(self):
        # Fog
        expfog = Fog("scene-wide-fog")
        expfog.setColor(0.5,0.5,0.5)
        expfog.setExpDensity(0.002)
        self.render.setFog(expfog)

        # Our sky
        skysphere = self.loader.loadModel('models/blue-sky-sphere')
        skysphere.setEffect(CompassEffect.make(self.render))
        skysphere.setScale(0.08)

        # NOT render or you'll fly through the sky!:
        skysphere.reparentTo(self.camera) 

        # Our lighting
        ambientLight = AmbientLight("ambientLight")
        ambientLight.setColor(Vec4(.6, .6, .6, 1))
        self.render.setLight(self.render.attachNewNode(ambientLight))

        directionalLight = DirectionalLight("directionalLight")
        directionalLight.setColor(VBase4(0.8, 0.8, 0.5, 1))
        dlnp = self.render.attachNewNode(directionalLight)
        dlnp.setPos(0,0,260)
        dlnp.lookAt(self.player)
        self.render.setLight(dlnp)

The Fog

Remember, we added fog to help blend the scene because we cropped the cameras maximum distance to improve performance back in Issue 3. Having said that, the real purpose of fog is a nice visual effect. Our code creates a Fog object. It then sets the Fog Color - 3 values are given. One for red, one for green, one for blue. You can make any colour mixing the three. The RGB values are floating point values (floats have a decimal point; integers do not) between zero and one. 0,0,0 is black. 1,1,1 is white. 1,0,0 is pure red. Go ahead and play with the values. We then set the fog density before re-parenting to render. Again, we encourage you to change the values, particularly if you have changed the 'maxdistance' variable in your constructor (we set ours to 400).

The Sky

We load our new sphere, scale and parent to camera. We add an effect – the CompassEffect. It is because of this that the sky doesn't twist and turn with the camera. That would have looked strange! This is akin to leaving the sky parented to the camera - but break their tie in terms of orientation (sell below section on node paths).

The Lighting

Panda3D offers a number of lighting types. We are using two (ambient and directional). The types of light available are:

  • Ambient light. If you go outside on a cloudy day, that's ambient light. There does not appear to be a 'source' of the light but it is present and consistent. Think of it as base-lighting.
  • Directional light. Like the sun. A directional light has a direction. It 'shines' infinitely in that direction.
  • A Point light. Like a light bulb. Light originates from a single point infinitely in all directions.
  • Spotlights. A spotlight has both a point and a direction, a field of view and a lens. The most complicated type of light on offer.

On to our code. We create an ambient light object, then we set the colour of the light. Interestingly, the method takes a Vec4 (4-point vector). Red, Green, Blue and Alpha (transparency). The alpha is largely irrelevant for lighting, we set it to 1 (no transparency). The colour values for a light can also be above 1 to make the light brighter. After creating the light we make a 'nested-call'. The line:

        self.render.setLight(self.render.attachNewNode(ambientLight))

We could have written this instead:

        somevariable = self.render.attachNewNode(ambientLight)
        self.render.setLight(somevariable)

Instead, we condensed down to one line of code and avoided a variable (they all do take up a little memory!) by using nested calls. As to what the code does and we need to explain that via explaining node paths. So what's a node path? Think of a node path as a tree you can navigate. A NodePath is actually a handle to PandaNodes. Your whole scene is made up of PandaNodes (of which there are several different types - ModellNode, LightNode etc.). These are organised into a tree, your Scene Graph.

To start traversing that tree from 'the point you choose' you get a 'handle', a NodePath, to that part of the tree. The root of the tree is 'render'. The diagram below should clarify (click the image to enlarge it).

The Scene Graph

Think of the tree in terms of parents and children. Render is the top-level parent (root). Where the parent goes, the children follow. That's why our sky dome worked. If you looked at the dome in OOBE mode you would have seen it was actually quite small! Much smaller than the world. Yet the sky worked because it was re-parented to the camera. When the camera moved, so did the sky dome, so its size was never revealed to the player (aside from, as we saw, flying high or far). The same applies to scale, orientation, etc. Put another way, if you call a method on a parent node, it's the same as calling it on all of its child nodes too.

It is also worth noting that we can, if we wish, create blank NodePaths for no purpose other than logistically grouping other nodes.

Back to our lighting. 🙂 We attached the ambient light node to the scene graph but got a handle back (a node path). We then told render that it was a light node via setLight.

We then create our directional light. A similar process. We grab the variable from attachNewNode this time instead of a nested call as we did with ambient lighting. We then use that to point our light at the player positioned from above.

10. Wrap up and some nice water!

Go ahead and add this at the end of your createEnvironment method:

        # water
        self.water = self.loader.loadModel('models/square.egg')
        self.water.setSx(self.worldsize*2)
        self.water.setSy(self.worldsize*2)
        self.water.setPos(self.worldsize/2,self.worldsize/2,25) # sea level
        self.water.setTransparency(TransparencyAttrib.MAlpha) 
        newTS = TextureStage('1')
        self.water.setTexture(newTS,self.loader.loadTexture('models/water.png'))
        self.water.setTexScale(newTS,4)
        self.water.reparentTo(self.render)
        LerpTexOffsetInterval(self.water,200,(1,0),(0,0), textureStage=newTS).loop()

You will need one more model and texture, download links are below, place the files in your models directory:

In our usual style, we're not going to discuss this until the next issue! The only part you may need to tweak for your game is the setPos call. The last parameter (25 in the code above) is your sea-level. Play around with that value to suit your game. On running your game, you should find you have nice animated water! It should also be semi-transparent allowing you to see the terrain beneath the sea. The animation is subtle, so slow down or run in OOBE mode if you cannot observe the motion in the ocean!

Motion in the Ocean

Now fly into the sea. Notice you do not crash! Go back to your setupCollisions routine and add one more bitmask:

        self.water.setCollideMask(BitMask32.bit(0))

Problem solved! Until next time, goodbye! There is one more page of this Issue, an Appendix detailing the full game code as it currently stands. In Issue 5 we will be adding a heads up display (HUD) and some sound while refactoring our code to support the games growth.

ISSUE 4 APPENDIX: Full Game Code

[python]
from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from direct.interval.LerpInterval import LerpTexOffsetInterval, LerpPosInterval
from pandac.PandaModules import CompassEffect, CollisionTraverser, CollisionNode
from pandac.PandaModules import CollisionSphere, CollisionHandlerQueue, Material
from pandac.PandaModules import VBase4, VBase3, TransparencyAttrib
from panda3d.core import AmbientLight, DirectionalLight, Vec4, Vec3, Fog
from panda3d.core import BitMask32, Texture, TextNode, TextureStage
from panda3d.core import NodePath, PandaNode
from direct.gui.OnscreenText import OnscreenText
import sys

class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
self.debug = False
self.statusLabel = self.makeStatusLabel(0)
self.collisionLabel = self.makeStatusLabel(1)

self.world = self.loader.loadModel("models/world.bam")
self.world.reparentTo(self.render)

# relevant for world boundaries
self.worldsize = 1024

self.maxspeed = 100.0
self.startPos = Vec3(200,200,65)
self.startHpr = Vec3(225,0,0)
self.player = self.loader.loadModel("models/alliedflanker")
self.player.setScale(.2,.2,.2)
self.player.reparentTo(self.render)
self.resetPlayer()

# A task to run every frame, some keyboard setup and our speed
self.taskMgr.add(self.updateTask, "update")
self.keyboardSetup()

# performance (to be masked later by fog) and view:
self.maxdistance = 400

if self.debug == False:
self.camLens.setFar(self.maxdistance)
else:
base.oobe()

self.camLens.setFov(60)
self.createEnvironment()
self.setupCollisions()
self.textCounter = 0

# load the explosion ring
self.explosionModel = self.loader.loadModel('models/explosion')
self.explosionModel.reparentTo(self.render)
self.explosionModel.setScale(0.0)
self.explosionModel.setLightOff()
# only one explosion at a time:
self.exploding = False

def resetPlayer(self):
self.player.show()
self.player.setPos(self.world,self.startPos)
self.player.setHpr(self.world,self.startHpr)
self.speed = self.maxspeed/2

def makeStatusLabel(self, i):
return OnscreenText(style=2, fg=(.5,1,.5,1), pos=(-1.3,0.92-(.08 * i)),align=TextNode.ALeft, scale = .08, mayChange = 1)

def keyboardSetup(self):
self.keyMap = {"left":0, "right":0, "climb":0, "fall":0, \
"accelerate":0, "decelerate":0, "fire":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])
base.disableMouse() # or updateCamera will fail!

def setKey(self, key, value):
self.keyMap[key] = value

def createEnvironment(self):
# Fog
expfog = Fog("scene-wide-fog")
expfog.setColor(0.5,0.5,0.5)
expfog.setExpDensity(0.002)
self.render.setFog(expfog)

# Our sky
skysphere = self.loader.loadModel('models/blue-sky-sphere')
skysphere.setEffect(CompassEffect.make(self.render))
skysphere.setScale(0.08)
# NOT render or you'll fly through the sky!:
skysphere.reparentTo(self.camera)

# Our lighting
ambientLight = AmbientLight("ambientLight")
ambientLight.setColor(Vec4(.6, .6, .6, 1))
self.render.setLight(self.render.attachNewNode(ambientLight))

directionalLight = DirectionalLight("directionalLight")
directionalLight.setColor(VBase4(0.8, 0.8, 0.5, 1))
dlnp = self.render.attachNewNode(directionalLight)
dlnp.setPos(0,0,260)
dlnp.lookAt(self.player)
self.render.setLight(dlnp)

# water
self.water = self.loader.loadModel('models/square.egg')
self.water.setSx(self.worldsize*2)
self.water.setSy(self.worldsize*2)
self.water.setPos(self.worldsize/2,self.worldsize/2,25) # sea level
self.water.setTransparency(TransparencyAttrib.MAlpha)
newTS = TextureStage('1')
self.water.setTexture(newTS,self.loader.loadTexture('models/water.png'))
self.water.setTexScale(newTS,4)
self.water.reparentTo(self.render)
LerpTexOffsetInterval(self.water, 200, (1,0),(0,0), textureStage=newTS).loop()

def setupCollisions(self):
self.collTrav = CollisionTraverser()

self.playerGroundSphere = CollisionSphere(0,1.5,-1.5,1.5)
self.playerGroundCol = CollisionNode('playerSphere')
self.playerGroundCol.addSolid(self.playerGroundSphere)

# bitmasks
self.playerGroundCol.setFromCollideMask(BitMask32.bit(0))
self.playerGroundCol.setIntoCollideMask(BitMask32.allOff())
self.world.setCollideMask(BitMask32.bit(0))
self.water.setCollideMask(BitMask32.bit(0))

# and done
self.playerGroundColNp = self.player.attachNewNode(self.playerGroundCol)
self.playerGroundHandler = CollisionHandlerQueue()
self.collTrav.addCollider(self.playerGroundColNp, self.playerGroundHandler)

# DEBUG as per video:
if (self.debug == True):
self.playerGroundColNp.show()
self.collTrav.showCollisions(self.render)

def updateTask(self, task):

self.updatePlayer()
self.updateCamera()

self.collTrav.traverse(self.render)
for i in range(self.playerGroundHandler.getNumEntries()):
entry = self.playerGroundHandler.getEntry(i)
if (self.debug == True):
self.collisionLabel.setText("DEAD:"+str(globalClock.getFrameTime()))
if (self.exploding == False):
self.player.setZ(entry.getSurfacePoint(self.render).getZ()+10)
self.explosionSequence()
return Task.cont

def updatePlayer(self):
# Global Clock
# by default, panda runs as fast as it can frame to frame
scalefactor = (globalClock.getDt()*self.speed)
climbfactor = scalefactor * 0.3
bankfactor = scalefactor
speedfactor = scalefactor * 2.9
gravityfactor = ((self.maxspeed-self.speed)/100.0)*2.0

# Climb and Fall
if (self.keyMap["climb"]!=0 and self.speed > 0.00):
# faster you go, quicker you climb
self.player.setZ(self.player.getZ()+climbfactor)
self.player.setR(self.player.getR()+(0.5*climbfactor))
# quickest return: (avoids uncoil/unwind)
if (self.player.getR() >= 180):
self.player.setR(-180)

elif (self.keyMap["fall"]!=0 and self.speed > 0.00):
self.player.setZ(self.player.getZ()-climbfactor)
self.player.setR(self.player.getR()-(0.5*climbfactor)) ###
# quickest return:
if (self.player.getR() <= -180): self.player.setR(180) # autoreturn - add a bit regardless to make sure it happens elif (self.player.getR() > 0):
self.player.setR(self.player.getR()-(climbfactor+0.1))
if (self.player.getR() < 0): self.player.setR(0) # avoid jitter elif (self.player.getR() < 0): self.player.setR(self.player.getR()+(climbfactor+0.1)) if (self.player.getR() > 0):
self.player.setR(0)

# Left and Right
if (self.keyMap["left"]!=0 and self.speed > 0.0):
self.player.setH(self.player.getH()+bankfactor)
self.player.setP(self.player.getP()+bankfactor)
# quickest return:
if (self.player.getP() >= 180):
self.player.setP(-180)
elif (self.keyMap["right"]!=0 and self.speed > 0.0):
self.player.setH(self.player.getH()-bankfactor)
self.player.setP(self.player.getP()-bankfactor)
if (self.player.getP() <= -180): self.player.setP(180) # autoreturn elif (self.player.getP() > 0):
self.player.setP(self.player.getP()-(bankfactor+0.1))
if (self.player.getP() < 0): self.player.setP(0) elif (self.player.getP() < 0): self.player.setP(self.player.getP()+(bankfactor+0.1)) if (self.player.getP() > 0):
self.player.setP(0)

# throttle control
if (self.keyMap["accelerate"]!=0):
self.speed += 1
if (self.speed > self.maxspeed):
self.speed = self.maxspeed
elif (self.keyMap["decelerate"]!=0):
self.speed -= 1
if (self.speed < 0.0): self.speed = 0.0 # move forwards - our X/Y is inverted, see the issue if self.exploding == False: self.player.setX(self.player,-speedfactor) self.applyBoundaries() self.player.setZ(self.player,-gravityfactor) def applyBoundaries(self): if (self.player.getZ() > self.maxdistance):
self.player.setZ(self.maxdistance)
# should never happen once we add collision, but in case:
elif (self.player.getZ() < 0): self.player.setZ(0) # and now the X/Y world boundaries: boundary = False if (self.player.getX() < 0): self.player.setX(0) boundary = True elif (self.player.getX() > self.worldsize):
self.player.setX(self.worldsize)
boundary = True
if (self.player.getY() < 0): self.player.setY(0) boundary = True elif (self.player.getY() > self.worldsize):
self.player.setY(self.worldsize)
boundary = True

# lets not be doing this every frame...
if boundary == True and self.textCounter > 30:
self.statusLabel.setText("STATUS: MAP END; TURN AROUND")
elif self.textCounter > 30:
self.statusLabel.setText("STATUS: OK")

if self.textCounter > 30:
self.textCounter = 0
else:
self.textCounter = self.textCounter + 1

def updateCamera(self):
# see issue content for how we calculated these:
percent = (self.speed/self.maxspeed)
self.camera.setPos(self.player, 19.6225+(10*percent), 3.8807, 10.2779)
self.camera.setHpr(self.player,94.8996,-12.6549, 1.55508)

def explosionSequence(self):
self.exploding = True
self.explosionModel.setPosHpr( Vec3(self.player.getX(),self.player.getY(), \
self.player.getZ()), Vec3( self.player.getH(),0,0))
self.player.hide()
taskMgr.add( self.expandExplosion, 'expandExplosion' )

def expandExplosion( self, Task ):
# expand the explosion rign each frame until a certain size
if self.explosionModel.getScale( ) < VBase3( 60.0, 60.0, 60.0 ): factor = globalClock.getDt() scale = self.explosionModel.getScale() scale = scale + VBase3( factor*40, factor*40, factor*40 ) self.explosionModel.setScale(scale) return Task.cont else: self.explosionModel.setScale(0) self.exploding = False self.resetPlayer() # and it stops the task app = MyApp() app.run() [/python]

Goto Issue 5 >>>

Viewing: Full Issue at Once (turn pages back on)