projectile and colllision tutorial image

This tutorial includes projectiles, simple fake physics, collisions, mouse interaction, and some handy vector math techniques to create a simple game.

There are several steps:

  1. Create a projectile and animate it through code
  2. Set up interaction to aim and launch the projectile
  3. Create and animate targets to hurl projectiles at
  4. Set up collision detection so you know when the target has been hit

All the completed code, with models and Maya files, is included in this archive:

TUTORIAL FILES

Projectiles

To animate a projectile, we'll use the same basic method as any other form of code-based animation:

  1. Create an update task
  2. Store a variable with a velocity vector
  3. Each time the update task runs, get the current position of the object using getPos()
  4. Add the velocity vector to the current position to get the new position
  5. Update the position of the object using setPos()
# -*- coding: utf-8 -*-

# Projectile example
import direct.directbase.DirectStart
from pandac.PandaModules import *
from direct.actor.Actor import Actor
from direct.interval.ActorInterval import ActorInterval
from direct.interval.IntervalGlobal import *
from direct.showbase.DirectObject import DirectObject
import MousePickingManager
import random

class Bullet:
  def __init__(self):
    # property for this class to store the current velocity    
    self.velocity=Vec3(0,0,0)
   
    # set up the update task to animate the bullet
    self.updateTask = taskMgr.add(self.update,"update")
    self.obj=loader.loadModel("models/ball")
    self.obj.reparentTo(render)
    self.obj.setScale(.25)
   
    # this variable is used to calculate the elapsed time between calls to the update function
    self.lastTime=0
   
  def setVelocity (self,v):
    self.velocity=v
     
  def setPos(self,v):
    self.obj.setPos(v)
 
  def update(self,task):
    # how much time elapsed since the last update
    dt=task.getElapsedTime()-self.lastTime
    self.lastTime=task.getElapsedTime()

    # multiply the elapsed time X the velocity
    delta = self.velocity * dt

    # add the amount of change onto the current position
    newPos = self.obj.getPos() + delta

    # update the position of the object
    self.obj.setPos(newPos)

    return task.cont

   
class World(DirectObject):
  def __init__(self):
    base.disableMouse()
   
    terrain=loader.loadModel("models/terrain")
    terrain.reparentTo(render)
    terrain.setScale(1,3,1)
   
    light1=render.attachNewNode(PointLight("light1"))
    light1.setPos(0,0,20)
    render.setLight(light1)
   
    camera.setPos(0,0,1)
    base.setBackgroundColor(.3,.3,7)
   
    # create a bullet
    bullet=Bullet()
   
    # give it an initial velocity
    v = Vec3(0,5,1)
    bullet.setVelocity(v)

w=World()
run()

This example uses two classes: one for the World, and one for the Bullet. This lets you create multiple bullets, just by creating more instances of the class, which we'll do a little later.

The core of the bullet code is this:

newPos = self.obj.getPos() + delta

where delta is the amount of change. But the way we calculate delta deserves some explanation. Notice the additional code before that point, dealing with time? This is important, but to understand why, let's look at a much simpler way we might try to write the same function:

def update(self,task):
    # add the amount of change onto the current position
    newPos = self.obj.getPos() + self.velocity

    # update the position of the object
    self.obj.setPos(newPos)

    return task.cont

Much simpler, right? In the example, the velocity vector is set to (0,5,1), which is 5 units forward along the Y axis and 1 unit up along the Z axis. Thinking just about the Y axis, this means the bullet moves 5 units forward every time the update() function is called. This will work, although it will be way to fast, such that the bullet disappears into space immediately. Try setting the velocity to something smaller:

v = Vec3(0,.1,.01)
    bullet.setVelocity(v)

and that'll slow it down so you can see it. So this works fine - why do we need all that stuff with the timing variables?

The reason is that using the timing variables lets you describe velocity in terms of actual relationships between distance and time. Also, without using timing variables, you have no way of knowing how quickly the update() task is really getting called - sometimes it'll be really fast, other times, as your scene gets heavier, it might get called less often, causing the motion of the projectile to slow down. Using timing variables solves this problem.

Ok, so what ARE the timing variables, anyway? Here's the idea: first, think about how we describe speed in the real world. We might say a car is going 60 miles per hour, or a fastball is moving at 80 miles per hour. To animate an object in this way, we need to know the time step between one position and the next, and use that to determine how much change should happen.

In the case of a baseball traveling 80 miles an hour, let's say the time step is 1 second (meaning our animation is running at 1 frame per second). There are 60 * 60 = 3600 seconds in an hour, so in each second the ball should move 80/3600 = .02222 miles. In more reasonable units, that's around 117 feet per second.

So a chart of the ball's progress would look like this:

Time (seconds)    Position (feet)
-------------------------------
0                       0
1                       117
2                       234
3                       351
...

"Change" is often labeled using the Greek symbol "Delta." The Time Step, the amount of time that elapses from one update to the next, is also called "Delta Time" or just "dT". The amount of change along one axis is often called Delta X, Delta Y, etc., or just dX and dY.

In Panda, Tasks don't have a framerate setting, they just run as fast as they possibly can, which will change depending on how complex the scene is and how fast the computer is. So in order to find out how long the time step, or dT is, we do this:

  1. Get the current timestamp using task.getElapsedTime()
  2. Subtract the timestamp of the last call to update(): dt=task.getElapsedTime()-self.lastTime
  3. Store the current timestamp so we can use it the next time update() is called: self.lastTime=task.getElapsedTime()

Then we multiply dt times the velocity vector before adding it on. If the velocity vector is expressed in meters per second, dt will contain a fraction of a second and so multiplying it times the velocity will give the correct amount of movement.

Adding Gravity

To create the effect of gravity, make another vector variable with the gravity direction in it, and add that (multiplied times dt) in each update.

# -*- coding: utf-8 -*-
import direct.directbase.DirectStart
from pandac.PandaModules import *
from direct.actor.Actor import Actor
from direct.interval.ActorInterval import ActorInterval
from direct.interval.IntervalGlobal import *
from direct.showbase.DirectObject import DirectObject
import MousePickingManager
import random

GRAVITY=Vec3(0,0,-2)

class Bullet:
  def __init__(self):
    # property for this class to store the current velocity    
    self.velocity=Vec3(0,0,0)
   
    # set up the update task to animate the bullet
    self.updateTask = taskMgr.add(self.update,"update")
    self.obj=loader.loadModel("models/ball")
    self.obj.reparentTo(render)
    self.obj.setScale(.25)
   
    # this variable is used to calculate the elapsed time between calls to the update function
    self.lastTime=0
   
  def setVelocity (self,v):
    self.velocity=v
     
  def setPos(self,v):
    self.obj.setPos(v)
 
  def update(self,task):
    # how much time elapsed since the last update
    dt=task.getElapsedTime()-self.lastTime
    self.lastTime=task.getElapsedTime()

    self.velocity=self.velocity+ (GRAVITY * dt)

    # multiply the elapsed time X the velocity
    delta = self.velocity * dt

    # add the amount of change onto the current position
    newPos = self.obj.getPos() + self.velocity * dt

    # update the position of the object
    self.obj.setPos(newPos)

    return task.cont

   
class World(DirectObject):
  def __init__(self):
    base.disableMouse()
   
    terrain=loader.loadModel("models/terrain")
    terrain.reparentTo(render)
    terrain.setScale(1,3,1)
   
    light1=render.attachNewNode(PointLight("light1"))
    light1.setPos(0,0,20)
    render.setLight(light1)
   
    camera.setPos(0,0,1)
    base.setBackgroundColor(.3,.3,7)
   
    # create a bullet
    bullet=Bullet()
   
    # give it an initial velocity
    v = Vec3(0,5,3)
    bullet.setVelocity(v)

w=World()
run()

Aiming

This code example sets up a gun and rotates it to follow the mouse. The gun is positioned just underneath the camera, and the update() task maps the X and Y movement of the mouse to the Heading and Pitch rotations of the gun. (Remember Heading is Left-Right rotation, and Pitch is Up-Down rotation).

There are a number of other ways to accomplish this effect, but this one is pretty straightforward. This example uses the "MousePickingManager" utility class, so you'll need that file in the same directory so you can import it; but this can also be done easily with Panda's built in mouse functions. MousePickingManager is used here just for consistency with some of the other examples.

# -*- coding: utf-8 -*-
import direct.directbase.DirectStart
from pandac.PandaModules import *
from direct.actor.Actor import Actor
from direct.interval.ActorInterval import ActorInterval
from direct.interval.IntervalGlobal import *
from direct.showbase.DirectObject import DirectObject
import MousePickingManager
import random

class World(DirectObject):
  def __init__(self):
 
    self.mousemanager=MousePickingManager.MousePickingManager(self)
   
    terrain=loader.loadModel("models/terrain")
    terrain.reparentTo(render)
    terrain.setScale(1,3,1)
   
    light1=render.attachNewNode(PointLight("light1"))
    light1.setPos(0,0,20)
    render.setLight(light1)
   
    camera.setPos(0,0,1)
   
    base.setBackgroundColor(.3,.3,7)
   
    self.gun=loader.loadModel("models/gun")
    self.gun.setPos(0,.8,.65)
    self.gun.setScale(.5)
    self.gun.reparentTo(render)

    self.accept("mouseDown",self.onMouseDown)
    taskMgr.add(self.update,"update")

  def onMouseDown(self):
    print "Click!"
   
  def update(self,task):
    # get the X,Y coordinates of the mouse, on a range from -1 to 1
    p=self.mousemanager.getMouse()
    # scale the X,Y mouse coordinates to rotations: -45 to 45 degrees left/right, and 0 to 30 degrees up
    self.gun.setHpr(p.getX() * -45,(p.getY()+1)*15,0)
    return task.cont

w=World()
run()


Firing

To fire the bullet, we add a "fire" function and call it from onMouseDown(). It doesn't necessarily need to be its own function, all that code could just go straight into onMouseDown(), but this keeps the code more organized.

The bullet is created here:

def fire(self):
    bullet = Bullet()
   
    fvec = Vec3(0,1,0)
    bullet.setVelocity(render.getRelativeVector(self.gun,fvec*20))
    bullet.setPos(self.gun.getPos() + render.getRelativeVector(self.gun,fvec))

As soon as it's created, it begins animating (through Bullet's update() methdo). The last three lines in the fire() function make the bullet launch in the direction the gun is pointing, and deserve some explanation.

The key is the function getRelativeVector:

Vec3 NodePath.getRelativeVector(NodePath N,Vec3 V)

which takes the vector V relative to NodePath N, and returns an equivalent vector relative to the NodePath you use to call the method. Here's how it's used.

the vector fvec (0,1,0) points forward along the Y axis - it's a Forward Vector. What we want is to make the bullet move forward, but in the direction the gun is pointing. We could get that effect by simply parenting the bullet to the gun, but then the bullet would move as the gun rotates even after it's in flight.

So what we want is a new vector, in world coordinates, pointing forward from the gun. That's where getRelativeVector comes in - given the forward vector in the gun's coordinate system, it finds the equivalent vector in the world render coordinate system.

Note that you can launch as many bullets as you want. The bullet variable is temporary, since it's a local variable within the fire() method; but the bullet objects you've spawned all stay around. In some cases you'll want to add these to a list or keep track of them in some other way, but for this example it won't be necessary - we can store everything we need via the scene graph.

# -*- coding: utf-8 -*-
import direct.directbase.DirectStart
from pandac.PandaModules import *
from direct.actor.Actor import Actor
from direct.interval.ActorInterval import ActorInterval
from direct.interval.IntervalGlobal import *
from direct.showbase.DirectObject import DirectObject
import MousePickingManager
import random

GRAVITY = Vec3 (0,0,-2)

class Bullet:
  def __init__(self):
    self.velocity=Vec3(0,0,0)
    self.updateTask = taskMgr.add(self.update,"update")
    self.obj=loader.loadModel("models/ball")
    self.obj.reparentTo(render)
    self.obj.setScale(.25)
    self.lastTime=0
   
  def setVelocity (self,v):
    self.velocity=v
    #print self.velocity
   
  def setPos(self,v):
    self.obj.setPos(v)
  def update(self,task):
    dt=task.getElapsedTime()-self.lastTime
    self.lastTime=task.getElapsedTime()
    #print dt,task.getDt()
    self.velocity = self.velocity + (GRAVITY * dt)
    #print task.getDt(),self.velocity,self.obj.getPos()
    delta = self.velocity * dt
    #print task.getElapsedTime(),self.velocity.getY(),self.obj.getY()
   
    #print delta.getY()
    newPos = self.obj.getPos() + (self.velocity * dt)
    if newPos.getZ() < 0:
      self.obj.setPos(newPos)
      self.obj.removeNode()
      taskMgr.remove(self.updateTask)
      del(self)    
    else:
      self.obj.setPos(newPos)
    #print self.obj.getPos()
    return task.cont

class World(DirectObject):
  def __init__(self):
    self.mousemanager=MousePickingManager.MousePickingManager(self)
   
    terrain=loader.loadModel("models/terrain")
    terrain.reparentTo(render)
    terrain.setScale(1,3,1)
   
    light1=render.attachNewNode(PointLight("light1"))
    light1.setPos(0,0,20)
    render.setLight(light1)
   
    camera.setPos(0,0,1)
   
    base.setBackgroundColor(.3,.3,7)
   
    self.gun=loader.loadModel("models/gun")
    self.gun.setPos(0,.8,.65)
    self.gun.setScale(.5)
    self.gun.reparentTo(render)
    #self.gun.setHpr(15,0,0)
    self.accept("mouseDown",self.onMouseDown)
    taskMgr.add(self.update,"update")
   
   
  def onMouseDown(self):
    self.fire()
   
  def fire(self):
    bullet = Bullet()
   
    fvec = Vec3(0,1,0)
    bullet.setVelocity(render.getRelativeVector(self.gun,fvec*20))
    bullet.setPos(self.gun.getPos() + render.getRelativeVector(self.gun,fvec))
       
  def update(self,task):
    p=self.mousemanager.getMouse()
    self.gun.setHpr(p.getX() * -45,(p.getY()+1)*15,0)
    return task.cont
   
w=World()
run()

Leave a Reply