November 24, 2010

ISSUE 3: Lights, Action, Camera!

3. Understanding the Code

The constructor (__init__ method) should look largely familiar, with a few additions. Line 12 stores the world size for reference. Lines 19-24 add a task to the task manager. The Panda3D task manager runs every frame. What we are saying here is to run our method called updateTask every frame. We define updateTask later in our code (lines 54-57). Other changes to our constructor call another method – keyboardSetup – which we define on lines 31 to 49, set an initial speed of zero, a maximum speed of 100 and scale the player model to 1/5 of its original size (this is purely cosmetic).

Perhaps the most important additions here are lines 26-29. When we ran the game on a fairly low end laptop with a baby sized graphics card the performance of the game was not admirable. Even if the game had executed painlessly you should still always consider performance (particularly if you plan on distributing your game where the ‘end-users PC specification’ is a big unknown). There are many approaches to improving performance but the one we opted for here was to call ‘setFar’ on the camera lens and set it to 400. This means that the anything further away than 400 from the camera lens is simply not drawn on the screen. Our world is quite large and this helps a lot (the default lens value is 1000). You probably noticed the game drawing the world ahead of the player as you flew around trying your new controls. Try changing the value of maxdistance and see what happens. We will later blend this ‘cut-off’ into our scene using some fog so it is less obvious to the end-user (player!).

We also change the FOV (field of view) here. This actually has a performance hit as, by default, the FOV is only 40 degrees. We’re widening the lens to 60 degrees as it gives a more ‘arcade flight game’ experience. Again, feel free to play with the values and see what happens!

Moving to line 31, we define our keyboardSetup method. Line 32 creates a variable called self.keyMap. The type of variable being created is called a ‘dictionary’ in Python (also called an ‘associative array’ or a ‘hash’). Very succinctly, our keyMap is a set of name-value pairs. Our names are the game controls – accelerate, decelerate, climb, fall, left, right, fire. The values are all set to zero. We could have setup several variables instead of a dictionary. A variable called ‘accelerate’ set to zero and so on. But, by ‘grouping’ them all in this way, all of our names/values are accessible from the one keyMap variable. To access values from a dictionary you would use code like this:

keyMap['accelerate'] = 0
keyMap['left'] = 1

Following the variable declaration, we have several lines calling ‘self.accept’. These lines are telling Panda3D to accept various key presses. Notice that there are two entries for each key we assign – the key being pressed down, and the key being release (‘up’). In every call to accept we pass 3 parameters: the key press we’re interested in, the method to call if the key press happens, the values to pass to that method when the key press happens. Thus:

self.accept("a", self.setKey, ["accelerate",1])
self.accept("a-up", self.setKey, ["accelerate",0])

….equates to:

  • If the ‘a’ key is pressed, call setKey with “accelerate” and “1”.
  • If the ‘a’ key is released, call setKey with “accelerate” and “0”.

We’re using zero and one here to indicate on or off (true/false). The actual setKey method is only 2 lines long (lines 51 and 52) and simply sets the value in our dictionary. By doing this, our keyMap dictionary contains, at all times, the state of the keyboard as last read. Any keys pressed will be captured as will keys being released. We will read back our keyMap later in the code when deciding how to move (fly) the player.

This brings us neatly now to our updateTask method (lines 54-57). Remember, this method gets called every frame of the game. It makes two calls of its own – one to updatePlayer and one to updateCamera (both of which are methods we provide in our code, see below). The last thing it does is ‘return Task.cont’. Task.cont is a constant we gained from the new import you may have noticed on line 2.

In any Python method, you can end by ‘returning’ a value. It’s a bit confusing in this particular instance as you cannot see updateTask being called – be assured that it is being called, frame by frame, by the task manager. To illustrate what a return value does consider the following snippet:

# simple method
def multiplyTwoNumbers(number1,number2):
    return (number1 * number2)

# calling the method
result = multiplyTwoNumbers(10,5)
# the variable 'result' now contains the value 50

If the updateTask method does not return Task.cont the task manager assumes the task is not longer needed and will stop executing it for further frames.

Next we hit the ‘big method’ – updatePlayer(). We will very likely be rewriting (or ‘refactoring’) this in the future for elegance and reuse but at this stage of play, it works admirably for our purposes. Deep breath…

We begin by declaring 4 variables (scalefactor, climbfactor, bankfactor, speedfactor). Notice that they are all related (climb, bank and speed factor are set relative to scale factor). Notice also that we omitted the “self.” you have been seeing prefixing variable names. This means that instead of adding the variables to the object (class instance) the variables are local variables. A local variable exists for a limited time only. The variables are created when the code is executed in updatePlayer and they exist until the end of the method. Once the method has finished executing the variables are discarded (they no longer exist and are deleted from memory). Deciding when to use local variables as opposed to member variables is a decision you will make often (and will see often in future issues).

What are these variables for? Notice the first:

scalefactor = (globalClock.getDt()*self.speed)

This makes use of the globalClock provided by Panda3D. The ‘getDt’ method returns the time since the last frame was drawn. This is another performance related tweak. We want to make sure that the game runs the same on different machines. By default, Panda3D will run ‘as fast as it can’, so you will get a FPS (frames per second) as high as your PC can handle (you don’t always want it to work this way and it can be changed, but for now, it allows our quick solution for controlling game speed). Thus, if you have a high end PC knocking out hundreds of frames per second, this value will be low, and so ‘scalefactor’ will be low. If you have a low end PC knocking out a few frames per second, this value will be high and so ‘scalefactor’ will be high.

We then use scale factor to calculate our climb, bank and speed factors. We will use these to control how fast the player can climb/fall, turn and move forwards. By tying these values to the world clock, we can be assured that the speed will be consistent whatever frame rate the end user is getting. As always, we encourage you to try playing with/changing the values to see what difference it makes to your game. You may prefer faster or slower responses than we have set.

Lines 67-89 deal with the player climbing/falling in the sky. A new important concept is introduced here – the ‘if statement’. An ‘if statement’ allows for conditionally executed code. ‘If’ a certain condition is matched do ‘some action’, if it is not ‘do some other action’. If we were to rewrite lines 67-89 in English it would look a little like this:

IF the player is trying to climb AND the player has some speed THEN do all of this:
    (**1) Adjust the players Z (height) and R (roll)
    IF the roll has gone beyond 180 degrees THEN
        set the roll to -180 degrees (**2)
OTHERWISE IF the player is trying to fall AND the player has some speed THEN:
    Adjust the players Z and R
    IF the roll has gone beyond -180 degrees THEN:
        set the roll to 180 degrees
OTHERWISE IF the player roll is greater than zero THEN:
    reduce it, avoid jitter
OTHERWISE IF the player roll is less than zero THEN:
    increase it, avoid jitter

Notice as always the indentation. In this case, we’re pushing that a little further by introducing a concept called ‘nesting’. Suppose the player presses the down arrow to climb in our game. The FIRST condition we have given (assuming they have some speed) will match (evaluate as true). Therefore, all of the code from (**1) to (**2) will execute. Note that this includes a further if-statement (the so called ‘nested’ if-statement). This is quite separate from the ‘outer’ statement and is executed independently. In other words, the ‘inner’ if-statement is only executed if the outer if-statement evaluates to true. We indicate this to Python as always by using a further level of indentation. This is quite unique to Python. A lot of programming languages do not enforce indentation instead opting to use control characters to group code. For example, in C++ an if-statement might look like this:

if (myVariable == 10) {
     do this
     do that
} else if {
} else {

C++ uses braces ( { and } ) to control where code blocks start and end. The upshot to this is that indentation becomes meaningless, the code could be written as:

if (myVariable == 10) { do this do that } else if { …. } else { …. }

…and it would still work. The downside is actually the same – we like the fact that Python forces you to lay out your code a certain way, it makes your code readable and this is important. Remember that at some point someone else may be picking up your code to enhance or change it. Trying to understand someone else’s code is always easier if it is laid out nicely! The other downside is that ‘common programming error 101’ for C++ programmers is a missing brace in the code leading to errors that are hard to find and debug. In Python, it’s obvious, because incorrect indentation jumps out like a sore thumb!

In short, then, we use ‘if’ and ‘elif’ in our code to indicate ‘IF’ and ‘OTHERWISE IF’ (or ‘else if’). Notice in the C++ code above there was a final statement that was simply ‘else’. This is equivalent to saying ‘if nothing else matches, do this…’. We don’t need this in our code at the moment but for reference, in Python, you would use ‘else’ in place of ‘elif’ on your final code block of the if-statement. To simplify futher:

  1. If the player climbs we change their Z and R. If R goes above 180 we set it to -180 which is actually exactly the same thing (but there is of course a reason we’re doing this!).
  2. Else If the player falls we change their Z and R. If R goes below -180 we set it to 180 (same)
  3. Else, ‘return to base’

When the player moves and we turn the model it is important that the model returns back to it’s normal position once the player has stopped moving (i.e., released the key). Try it in your game, we do a similar thing on left/right which is easier to observe. If you fly and bank hard to the left/right then release the key – notice the player ‘auto pilots’ back to the default position. Back to climb/fall: if neither key is pressed, we check the roll – if it’s greater than zero, reduce it. If it’s less than zero, increase it. The further ‘nested-if’ avoids something we call ‘jitter’. Imagine if the roll was very slightly above zero – we reduce it, it is slightly below zero. The next time updatePlayer executes (next frame) the opposite will happen – it’s slightly below zero, we increase it. And thus you can end up with an infinite sequence of slight alterations to the model transformation which would jitter (flicker) on the screen. To avoid this, we simply say “if it’s above zero, reduce it, if it is now below zero, set it to zero” and vice versa.

Adjust the roll?

Well spotted if you noticed! Based on our previous diagram you would expect to adjust the model pitch for climb/fall and not the model roll. What’s going on? Time for another deep breath (on the plus side, you’ll get a brief break from the code explanation!)…