Point & Click Player Movement Over Uneven Terrain

This script is somewhat similar to the ‘Roaming Ralph’ example, but instead of using keyboard keys to move the player around, it uses point & click mouse controls (just like Dungeon Siege and NeverWinter Nights :smiley:).

It’s taken me ages to finish it and I couldn’t have done it without the help of some very patient and very talented people here on these forums.

So a huge thankyou goes to Sandman (for his original BoardWalker code), Russ, Ynjh_jo, Fixer, Yellow and Martin, for all their help and considerable patience with this. You guys are amazing!!!

A couple of things to note before getting to the code: I used my own terrain model (bumpy_grass), so you’ll need to replace the terrain model with your own (but don’t use the world model from the Roaming Ralph example, because it’s not designed for point & click controls, if you try to use it you’ll find your player walking up the walls :unamused: ).

Oh! The terrain model was edited by hand to add collision tags and a start position. If you want to do this to your own model, open it in Word, then add the highlighted text to the top of your model file:

To add a start position to your model, add the following code at the end of your model file (the numbers 0.0 0.0 1.7 represent the X,Y,Z coordinates, so play with these to change the position. I don’t know what the 1 on the end is for, so I just leave it alone :wink: ):

<Group> start_point {
  <DCS> { net }
  <Transform> {
    <Matrix4> {
      1 0 0 0
      0 1 0 0
      0 0 1 0
      0.0 0.0 1.7 1
    }
    <Translate> { 0 0 0 }
  }
}

Well, here it is, mouseWalker.py (hope you like it :smiley:):

# Left click on the ground to move.
# Rotate the camera by moving the mouse pointer to the edges of the screen or
# with the left & right arrow keys.
# Zoom the camera with the mouse wheel or the up & down arrow keys.

import direct.directbase.DirectStart # Start Panda.
from pandac.PandaModules import * # Import the Panda Modules.
from direct.showbase.DirectObject import DirectObject # To handle Events.
from direct.task import Task # To use Tasks.
from direct.actor import Actor # To use animated Actors.
from direct.interval.IntervalGlobal import * # To use Intervals.
# We need to import this function for the player's rotation to work properly.
from direct.showbase.PythonUtil import closestDestAngle
import sys

class Controls(DirectObject):
    def __init__(self):
        base.disableMouse() # Disable default camera.
        self.loadModels()
        self.setupCollisions()
        # Declare variables.
        self.position = None

        self.playerMovement = None
        self.movementSpeed = 6.0 # Controls how long it takes the player to
        # move to the clicked destination.
        self.speed = .10 # Controls the speed of the camera's rotation and zoom.
        # Setup controls
        self.accept("escape", sys.exit)
        self.accept("player-stopped", self.stopWalkAnim)
        self.accept("mouse1", self.moveToPosition)
        self.accept("arrow_left", self.cameraTurn,[-1])
        self.accept("arrow_right", self.cameraTurn,[1])
        self.accept("arrow_up", self.cameraZoom,[-1])
        self.accept("arrow_down", self.cameraZoom,[1])
        self.accept("wheel_up", self.cameraZoom,[-1])
        self.accept("wheel_down", self.cameraZoom,[1])
        taskMgr.add(self.edgeScreenTracking, "edgeScreenTracking")
        # End __init__
       
    def loadModels(self):
        # Load an environment
        self.environ = loader.loadModel("models/bumpy_grass")
        self.environ.reparentTo(render) # Place it in the scene.
        self.environ.setPos(0, 0, 0)
        self.environ.setHpr(0, 0, 0)
       
        # For the camera to rotate independently of the player a 'player dummy
        # node' and a 'camera dummy node' need to be created. Both dummy nodes
        # are then 'parented' to the 'player dummy node' making them "siblings"
        # under the player dummy node. This means that any transformations
        # performed on the dummy node will be inherited by the player model and
        # the camera. Moving the player dummy node will move both the player
        # model and the camera, but moving or rotating the player model itself
        # won't effect the camera (because the camera isn't directly parented
        # to it).
       
        # Create the player's dummy node.
        self.player_dummy_node = render.attachNewNode("player_dummy_node")
        # Position the dummy node.
        self.player_dummy_node.setPos(0, 0, 0)
        self.player_dummy_node.setHpr(0, 0, 0)
        # The terrain model was edited by hand to include a start position for
        # the player. Use the Find command to locate it.
        self.playerStart = self.environ.find("**/start_point").getPos()
        # Now load the player model and its animations.
        self.player = Actor.Actor("models/ralph",{"walk":"models/ralph-walk"})
        # Set the player to the start position.
        self.player.setPos(self.playerStart)
        # Attach/parent the player model to the player dummy node.
        self.player.reparentTo(self.player_dummy_node)
        # The player model is too large, so scale it down by 50%.
        self.player.setScale(.5)
        # Now create the camera dummy node.
        self.camera_dummy_node = render.attachNewNode("camera_dummy_node")
        # Attach/parent the camera dummy node to the player dummy node.
        self.camera_dummy_node.reparentTo(self.player_dummy_node)
        # Attach/parent the main camera to the camera dummy node.
        camera.reparentTo(self.camera_dummy_node)
        # Position the main camera.
        camera.setPos(0, -35, 18) # X = left & right, Y = zoom, Z = Up & down.
        camera.setHpr(0, -25, 0) # Heading, pitch, roll.
        # End loadModels
       
    # Define a function to setup collision detection. We need two rays, one
    # attached to the camera for mouse picking and one attached to the player
    # for collision with the terrain. The rays must only cause collisions and
    # not collide with each other so their Into bitMasks are set to allOff().
    def setupCollisions(self):
        # The terrain model was edited by hand to include the following tag:
        # <Collide> Plane01 { Polyset keep descend }.
        #Once we have the collision tags in the model, we can get to them using
        # the NodePath's find command.
        self.ground = self.environ.find("**/Plane01")
        # Set the model's Into collide mask to bit (0). Now only objects that
        # have their From bitmask also set to (0) can collide with the terrain.
        self.ground.node().setIntoCollideMask(BitMask32.bit(0))
        # Create a CollisionTraverser for the picker ray. CollisionTraversers
        # are what do the job of calculating collisions.
        self.picker = CollisionTraverser()
        # Create a handler for the picker ray
        self.queue = CollisionHandlerQueue()
        # Make a collision node for our picker ray
        self.pickerNode = CollisionNode('mouseRay')
        # Attach that node to the camera since the ray will need to be positioned
        # relative to it.
        self.pickerNP = camera.attachNewNode(self.pickerNode)
        # Set the collision node's From collide mask. Now the ray can only cause
        # collisions with objects that have bitMask(0) such as the terrain.
        self.pickerNode.setFromCollideMask(BitMask32.bit(0))
        # Set the collision node's Into collide mask to allOff so that nothing
        # can collide into the ray.
        self.pickerNode.setIntoCollideMask(BitMask32.allOff())
        # Make our ray
        self.pickerRay = CollisionRay()
        # Add it to the collision node
        self.pickerNode.addSolid(self.pickerRay)
        #Register the ray as something that can cause collisions with the traverser
        self.picker.addCollider(self.pickerNP, self.queue)
       
        # Setup collision stuff to handle the player's collision with the terrain.
        # Make a collision node for the player's ray.
        self.groundCol = CollisionNode('playerRay')
        # Make a collision ray for the player.
        self.groundRay = CollisionRay()
        # Attach the collision node to the player dummy node.
        self.groundColNp = self.player_dummy_node.attachNewNode(self.groundCol)
        # Set the height of the ray (7 units above the player's head)
        self.groundRay.setOrigin(0, 0, 7)
        # Set the rays direction (pointing down on the Z axis)
        self.groundRay.setDirection(0, 0, -1)
        # Add the collision node to the collision ray
        self.groundCol.addSolid(self.groundRay)
        # Set the collision node's From collide mask. Now the ray can collide
        # with objects (like the terrain) that also have bitMask(0).
        self.groundCol.setFromCollideMask(BitMask32.bit(0))
        # Set the collision node's Into collide mask to allOff so that nothing
        # can collide into the ray.
        self.groundCol.setIntoCollideMask(BitMask32.allOff())
        # Make a CollisionTraverser. This will be used in the correctPlayerZ 
        # function.
        self.Zcoll = CollisionTraverser()
        # Make a handler for the ground ray. This will be used in the 
        # correctPlayerZ function.
        self.ZcollQueue = CollisionHandlerQueue()
        # Register it as something that can cause collisions with the traverser.
        self.Zcoll.addCollider(self.groundColNp, self.ZcollQueue)
       
        # Uncomment this line to see the collisions
        # self.Zcoll.showCollisions(render)

        # Uncomment this line to see the collision rays
        # self.groundColNp.show()
        # End setupCollisions

       
    # Define a task to monitor the position of the mouse pointer & rotate
    # the camera when the mouse pointer moves to the edges of the screen.
    def edgeScreenTracking(self,task):
        # Check if the mouse is available
        if not base.mouseWatcherNode.hasMouse():
            return Task.cont
        # Get the relative mouse position, its always between 1 and -1
        mpos = base.mouseWatcherNode.getMouse()
        if mpos.getX() > 0.99:
            self.cameraTurn(1)
        elif mpos.getX() < -0.99:
            self.cameraTurn(-1)
        return Task.cont
        # End edgeScreenTracking
       
    # Define the CameraTurn function.
    def cameraTurn(self,dir):
        self.camTurn = LerpHprInterval(self.camera_dummy_node, self.speed, Point3(self.camera_dummy_node.getH()-(10*dir), 0, 0))
        self.camTurn.start()
        # End cameraTurn
   
    # Define the cameraZoom function.
    def cameraZoom(self,dir):
        self.camZoom = LerpPosInterval(camera, self.speed, Point3(camera.getX(), camera.getY()-(2*dir), camera.getZ()+(.8*dir)))
        self.camZoom.start()
        # End cameraZoom
   
    # Define a function to correct the player's Z axis so that he follows the
    # contours of the ground.
    def correctPlayerZ(self, time):
        startpos = self.player.getPos()
        # Check for collisions
        self.Zcoll.traverse(render)
        if self.ZcollQueue.getNumEntries > 0:
         self.ZcollQueue.sortEntries()
         point = self.ZcollQueue.getEntry(0).getSurfacePoint(self.environ)
         self.player.setZ(point.getZ())
        else:
           self.player.setPos(startpos)
        # End correctPlayerZ

    # Define a function to get the position of the mouse click on the terrain.
    def getPosition(self, mousepos):
        self.pickerRay.setFromLens(base.camNode, mousepos.getX(),mousepos.getY())
        # Now check for collisions.
        self.picker.traverse(render)
        if self.queue.getNumEntries() > 0:
            self.queue.sortEntries()
            # This is the clicked position.
            self.position = self.queue.getEntry(0).getSurfacePoint(self.environ)
            # Set its Z axis to remain on the ground.
            self.position.setZ(0)
            return None
        # End getPosition
   
    # Define a function to make the player turn towards the clicked position
    # and then move to that position.   
    def moveToPosition(self):
        # Get the clicked position.
        self.getPosition(base.mouseWatcherNode.getMouse())
        # Create a dummy node.
        self.npLook = render.attachNewNode("npLook")
        # Calculate its position.
        self.npLook.setPos(self.player.getPos(render))
        # Make it look at the clicked position.
        self.npLook.lookAt(self.position)
        # Prevent overturning or 'wrap-around' by adjusting the player's heading
        # by 360 degrees.
        reducedH = self.player.getH()%360.0
        # Set the player's heading to that value.
        self.player.setH(reducedH)
        # Get the player's new heading.
        currH = self.player.getH()
        # Get the dummy node's heading.
        npH = self.npLook.getH()
        # Ralph was modeled facing backwards so we need to add 180 degrees to
        # stop him walking backwards. If your model is not modeled backwards
        # then delete the + 180.0.
        newH = closestDestAngle(currH, npH + 180.0)
        # Create a turn animation from current heading to the calculated new heading.
        playerTurn = self.player.hprInterval(.2, Point3(newH, 0, 0))
        # Calculate the distance between the start and finish positions.
        # This is then used to calculate the duration it should take to
        # travel to the new coordinates based on self.movementSpeed.
        travelVec = self.position - self.player_dummy_node.getPos()
        distance = travelVec.length()
        # Create an animation to make the player move to the clicked position.
        playerMove = self.player_dummy_node.posInterval((distance / self.movementSpeed), self.position)
        # We create a LerpFunc Interval to correct the Z axis as we go along.
        # So that the player stays on the ground.
        playerPositionZ = LerpFunc(self.correctPlayerZ, duration=(distance / self.movementSpeed))
       
        # Put the animations into a parallel sequence and set the doneEvent.
        if self.playerMovement:
           self.playerMovement.setDoneEvent("")
        self.playerMovement = Parallel(playerTurn, playerMove, playerPositionZ)
        self.playerMovement.setDoneEvent("player-stopped")
        # Play the walk animation.
        self.player.loop("walk")
        self.playerMovement.start()
        # End moveToPosition
       
    def stopWalkAnim(self):
        # This is called when the movement animation has finished.
        # We can then stop the walk animation.
        self.player.stop("walk")
        self.player.pose("walk",17)
        self.playerMovement = None
       
c = Controls()

run()

This wonderful script now forms the base code of my game. I plan to add to it and enhance it over time (but as I’m only just learning, it may take a considerable amount of time :unamused: ).

As always though, once I figure something out and get it working, I’ll post the updated code here in the forum.

Cheers

[quote=“Tiptoe”]
This script is somewhat similar to the ‘Roaming Ralph’ example, but instead of using keyboard keys to move the player around, it uses point & click mouse controls (just like Dungeon Siege and NeverWinter Nights :smiley:).

/quote]

Thats absolutely wonderful…thx tons for posting that, ,Ive thought about the desire/need for just something like this.

I’ll give it a spin latre and look fwd to your continued reports on your end too.

cheers
neighborlee(()

At the moment this script is still a work in progress. See this thread for the latest version.

Cyan has improved this piece of code beyond belief. I am eternally grateful to him for all the time and effort he’s put into this. He is incredibly clever and very talented.

He has also fixed it so that you can now use the terrain model from the ‘Roaming Ralph’ example. So please test it out and give Cyan all the feedback and praise that he rightfully deserves.

Thanks everyone.