Page 3

ISSUE 7: Do you read me, HAL?

Time to add some Artificial Intelligence!  We'll be giving the bad guys pursue, engage, flyby and flee behaviours.  How do we do this without all bad guys behaving identically?  Smoke and mirrors or a ghost in the machine...?
  • Delicious
  • Digg
  • Reddit
  • StumbleUpon
  • Twitter

5. More elaborate State Machines

Panda3D does actually include classes specifically for FSMs. We have opted not to use them at this stage (our machine and logic is really quite simple, implemented with an ‘if’ statement!) but may revert to them in the future if needed. If you are interested we recommend viewing the Panda3D Manual Page for FSMs. If you’re feeling adventurous, try and adapt the code we have provided to use the FSM classes from the Panda3D library. A short example is shown below:

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()

6. Screen resolutions

Does your game support multiple screen resolutions? Yes, it does! Changing screen resolution can be done quite easily in code. There is also a global configuration file for Panda3D called config.prc that sets the defaults (a future topic) and you can also have a separate .prc file per project/game (again, a future topic). In code, you can simply open up volume1/game.py and add, after your import statements, the lines:

    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

In the example given, the screen is set to a width of 1024 and a height of 768. We suggest keeping with standard screen resolutions and suggest keeping the resolution relatively low: 640×480 (VGA), 800×600 (SVGA), 1024×768 (XGA), 1280×768 (WXGA) or 1280×960 (SXGA-).

We tested in a number of other resolutions too and the game worked perfectly. The only instances of any problems we could find pertained to the on screen text messages. We fixed this via modifying the makeStatusLabel method in game.py to read as follows:

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

While we are here, this method really belongs in our Game GUI and not the main logic. Open up gui.py and move the makeStatusLabel method from game.py (ArcadeFlightGame) into gui.py (GameGUI). Then, in the ArcadeFlightGame constructor, move the lines:

        self.climbfactor = self.scalefactor * 0.2

…to after the declaration of self.gui and change them to read:

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

You will also need to add imports to gui.py:

    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())

…the same imports can be removed now from game.py.

Since we have covered default parameters in this Issue we may as well quickly cover named parameters! Look at the call to OnscreenText in makeStatusLabel. The parameters are named. This helps code readability but also means parameters can be in a different order. This approach is used quite often when there are many parameters and many have default values (i.e., you only want to pass a few values in, not all, so we make it clear what we are passing).

Would you like to try the game running in full screen? You can do it via adding the line:

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

…after the import. 🙂 You can also change the title bar for when in Windowed mode (it currently reads ‘Panda’ as we’re sure you noticed):

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

7. Picture in Picture (PIP) and taking Screenshots

Picture in Picture

We thought it might be nice to have a ‘picture in picture’ feature to the game. Let the player see the game as normal but, at the same time, some ‘mini screens’ showing a left, right and backward looking viewpoint of the action. It’s actually quite easy to do!

First, our updated constructor in game.py declares the new cameras:

    def __init__(self):
        ShowBase.__init__(self)
        self.debug = False
        self.maxdistance = 400
        self.player = AlliedFlanker(self.loader,self.render,self.taskMgr)
        self.world = GameWorld(1024,self.loader,self.render,self.camera)
        self.gui = GameGUI(self.render2d)
        self.statusLabel = self.gui.makeStatusLabel(0)
        self.collisionLabel = self.gui.makeStatusLabel(1)
        self.taskMgr.add(self.updateTask, "update")
        self.keyboardSetup()
 
        # performance and map to player so can't fly beyond visible terrain
        self.player.setMaxHeight(self.maxdistance)
 
        self.leftCam = base.makeCamera(base.win, \ 
                            displayRegion = (0.79, 0.99, 0.01, 0.21)) 
        self.rightCam = base.makeCamera(base.win, \ 
                              displayRegion = (0.79, 0.99, 0.23, 0.43)) 
        self.backCam = base.makeCamera(base.win, \ 
                              displayRegion = (0.79, 0.99, 0.45, 0.65)) 
        if self.debug == False:
            self.camLens.setFar(self.maxdistance)
            self.leftCam.node().getLens().setFar(self.maxdistance*2.0)
            self.rightCam.node().getLens().setFar(self.maxdistance*2.0)
            self.backCam.node().getLens().setFar(self.maxdistance*2.0)
        else:
            base.oobe()
 
        self.camLens.setFov(60)
        self.leftCam.node().getLens().setFov(60)
        self.leftCam.node().setActive(False)
        self.rightCam.node().getLens().setFov(60)
        self.rightCam.node().setActive(False)
        self.backCam.node().getLens().setFov(60)
        self.backCam.node().setActive(False)
 
        self.setupCollisions()
        self.textCounter = 0
 
        newGameSound = base.loader.loadSfx("volume1/sounds/gamestart.wav")
        newGameSound.play()
 
        self.level = 1; self.score = 0; self.baddies = list()
        self.startLevel()
        self.enemyCam = False; self.currentEnemy = 0;

Next, we update our keyboardSetup method. We will use the ‘p’ key for PIP and will only show PIP while the key is held down. Once released, we will again hide the PIP cameras. The code below also adds support for the ‘s’ key which we will use for taking screen shots in a moment:

        self.keyMap = {"left":0, "right":0, "climb":0, "fall":0, \
                        "accelerate":0, "decelerate":0, "fire":0, \ 
                        "cam":0, "pip":0, "shot":0}
        self.accept("p", self.setKey, ["pip",1])
        self.accept("p-up", self.setKey, ["pip",0])
        self.accept("s", self.setKey, ["shot",1])
        self.accept("s-up", self.setKey, ["shot",0])

In your actionInput method, activate/deactivate the cameras:

        if (self.keyMap["pip"]!=0 and self.enemyCam == False):
            self.leftCam.node().setActive(True)
            self.rightCam.node().setActive(True)
            self.backCam.node().setActive(True)
        else:
            self.leftCam.node().setActive(False)
            self.rightCam.node().setActive(False)
            self.backCam.node().setActive(False)

All we need to do now is control what these cameras are looking at. Modify the updateCamera method as follows:

    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)
            self.player.lookAtMeLeft(self.leftCam)
            self.player.lookAtMeRight(self.rightCam)
            self.player.lookAtMeBack(self.backCam)

The supporting methods need to be added to AlliedFlanker in player.py as shown below:

    def lookAtMeLeft(self,camera):
        camera.setPos(self.player,0,16,3)
        camera.setHpr(self.player,-180,-20,0)
 
    def lookAtMeRight(self,camera):
        camera.setPos(self.player,0,-16,3)
        camera.setHpr(self.player,0,-20,0)
 
    def lookAtMeBack(self,camera):
        camera.setPos(self.player,-9,1.5,3)
        camera.setHpr(self.player,-90,-30,0)

That’s it for PIP! Give it a try by launching the game then holding down the ‘P’ key. Finally, to add in screen shot support, add the following to actionInput in game.py:

        if (self.keyMap["shot"]!=0):
            base.screenshot()

Run the game, press the ‘S’ key. Now quit the game. You should find a new image file in the main game directory! A screen shot of the game as it was when you pressed the ‘S’ key! Simples. 🙂

8. Wrap up and Summary

A lot of ground covered! We introduced AI and implemented a simple state machine for our enemy. We need to add enemy collisions (and sounds, and damage, and firing!) but will return to those in the future! We have also added a nice PIP feature along with the ability to take screen shots. Quite a productive Issue, tune in next time for a special Issue that focuses on and addresses the feedback our readers have given us!

Quick Nav: Previous page |

Index: page 1 | page 2 | page 3 |
All: View full Issue on one Page