TGB/Tutorials/Pacman

From TDN


Genre Tutorial: Boxy - A Pacman Clone


Article by: Gavin Koh dated 5 August 2010 23:15 local time Singapore

Written with TGB Version: 1.75
Tutorial Revision History
v1 - 5 Aug 2010: First version

Introduction

This tutorial explains how I spent a couple of days in July implementing Boxy - a pacman clone. I have a blog [1] showing a couple of videos of Boxy in action, which you can view over here. I suggest you take a look at the blog first to have an idea of how I went about the Boxy project in stages.

PS - IMPORTANT: For those who don't like reading tutorials, you can dabble with the source code straight away by downloading it from my TGB resource website. You will find the link to the source code and art at the end of this tutorial.

Part 1 - Graphics

First off, a word about the graphics. I used Tile Studio [2], a great app available from sourceforge, to create Boxy. Nope, this is not a shameless plug for the app - it's up to you if you want to try it out for editing sprites. I personally prefer it.

For the star of the game - Boxy the pacman, I actually made him using the filled rounded rectangle tool. The ghosts, dots (pac-dots), and superdots (power pellets) were also drawn using Tile Studio.

PS - I wish I could upload graphics in TDN, but I was told there is a bug that would prevent images from being added. Well, just head to the end of this article and download the art from my website.

Part 2 - Source code structure

There are four source files in the game that matter. They are:

  • pacclass.cs - it contains an initialization section, the keybinds via the arrow keys, a section that handles pacman movement and animation, the pacman collision handler, and some support routines.
  • ghostclass.cs - after the initialization section, you will find a section that handles ghost movement, and a section that handles the eyes of the ghosts.
  • gridlayerclass.cs - this one is simple really... it just sets the weights of each wall tile in the maze
  • score.cs - nothing spectacular here, read the comments within.

I will spend a bit more time breaking down both pacclass.cs and ghostclass.cs.

Part 3a - pacclass.cs

The first part that handles initialization (shown below), is rather straightforward. The arrow keys are binded and more importantly, enableUpdateCallback is executed so that onUpdate is visited every 32 msecs.

//initialise the game upon level loaded
function pacClass::onLevelLoaded(%this, %sceneGraph)
{
   $pac = %this;
   
   $pac.previous = $stop;
   $pac.direction = $stop;
   
   moveMap.bindCmd(keyboard, "up", "pacUp();", "");
   moveMap.bindCmd(keyboard, "down", "pacDown();", "");
   moveMap.bindCmd(keyboard, "left", "pacLeft();", "");
   moveMap.bindCmd(keyboard, "right", "pacRight();", "");

   //enables the 32msec call to pac::onUpdate
   $pac.enableUpdateCallback();
}

Next, we see the keybinds section. There is a current (.direction) and previous (.previous) state for the direction that Boxy is moving in.

//up arrow pressed
function pacUp()
{
   $pac.previous = $pac.direction;
   $pac.direction = $up;
}

//down arrow pressed
function pacDown()
{
   $pac.previous = $pac.direction;
   $pac.direction= $down;
}

//left arrow pressed
function pacLeft()
{
   $pac.previous = $pac.direction;   
   $pac.direction = $left;
}

//right arrow pressed
function pacRight()
{
   $pac.previous = $pac.direction;   
   $pac.direction = $right;
}

The Pacman Movement section deals with the movement of Boxy through the maze. Every 32 msecs, onUpdate is called and it checks which direction Boxy is supposed to move in both the horizontal and vertical directions. (I could have merged these two functions into one, but then it's a matter of personal taste.) More importantly is the next function which plays the correct animation of Boxy. Based on the direction that Boxy is moving, either the up, down, left, or right animations are played. There is also a stop animation of Boxy which is currently not used and has been commented away.

//this function is called every 32msec thereafter upon level loaded
function pacClass::onUpdate(%this)
{
   %this.updateHorizontal();
   %this.updateVertical();
   %this.setCurrentAnimation();
}

//check if we are to move left or right or stop moving
function pacClass::updateHorizontal(%this)
{
   switch (%this.direction)
   {
      case $stop:
         %this.setLinearVelocityX(0);
         %this.setLinearVelocityY(0);         
      case $left:
         %this.setLinearVelocityX(-15);
         %this.setLinearVelocityY(0);         
      case $right:
         %this.setLinearVelocityX(15);
         %this.setLinearVelocityY(0);         
   }
}

//check if we are to move up or down or stop moving
function pacClass::updateVertical(%this)
{
   switch (%this.direction)
   {
      case $stop:
         %this.setLinearVelocityX(0);      
         %this.setLinearVelocityY(0);
      case $up:
         %this.setLinearVelocityX(0);
         %this.setLinearVelocityY(-15);
      case $down:
         %this.setLinearVelocityX(0);      
         %this.setLinearVelocityY(15);
   }
}

//check which animation to play when moving pacman in that direction
function pacClass::setCurrentAnimation(%this)
{
   switch (%this.direction)
   {
      //play animation of pacman at a complete stop
      //case $stop:
      //   if (%this.getAnimationName() $= "SBoxyPacAnimation")
      //   {
      //      if(%this.getIsAnimationFinished())
      //      {
      //         %this.playAnimation(SBoxyPacAnimation);
      //         return;
      //      }
      //   }
      //   else
      //   {
      //      %this.playAnimation(SBoxyPacAnimation);
      //   }
      //play animation of pacman moving upwards
      case $up:
         if (%this.getAnimationName() $= "UBoxyPacAnimation")
         {
            if(%this.getIsAnimationFinished())
            {
               %this.playAnimation(UBoxyPacAnimation);
               return;
            }
         }
         else
         {
            %this.playAnimation(UBoxyPacAnimation);
         }
      //play animation of pacman moving downwards         
      case $down:
         if (%this.getAnimationName() $= "DBoxyPacAnimation")
         {
            if(%this.getIsAnimationFinished())
            {
               %this.playAnimation(DBoxyPacAnimation);
               return;
            }
         }
         else
         {
            %this.playAnimation(DBoxyPacAnimation);
         }
      //play animation of pacman moving left         
      case $left:
         if (%this.getAnimationName() $= "LBoxyPacAnimation")
         {
            if(%this.getIsAnimationFinished())
            {
               %this.playAnimation(LBoxyPacAnimation);
               return;
            }
         }
         else
         {
            %this.playAnimation(LBoxyPacAnimation);
         }
      //play animation of pacman moving right
      case $right:
         if (%this.getAnimationName() $= "RBoxyPacAnimation")
         {
            if(%this.getIsAnimationFinished())
            {
               %this.playAnimation(RBoxyPacAnimation);
               return;
            }
         }
         else
         {
            %this.playAnimation(RBoxyPacAnimation);
         }
   }
}

I feel that the true heart of pacman is the Collision Handler. My final version is different from this one (because I had implemented it differently in the end, and its more optimised), but this one works almost just as well.

The onCollision method is quite long, but the comments are quite readable and the implementation is simplistic. There is of course a check for whether Boxy has collided on the grid. He can collide with dots, superdots, and walls (in that priority order). Boxy scores points for every eaten dot and superdot. Once eaten, the tile has to be cleared. If a superdot is eaten, the ghosts are forced to scatter to the four winds.

Wall collision is handled next. The walls were originally labelled "wall", but are then converted to a weight of 10 in gridlayerclass. So whenever you see a statement checking say, %lefttile $= 10, it's checking for a wall. Alright?

Now, onwards we go. Based on the current tile that Boxy is in, a check is made to count how many walls surround him. If there are 3 walls, Boxy is in a dead end passage and the only obvious direction is back the way he came. If there are 2 walls, it gets slightly complicated, essentially, Boxy is heading down a passageway and he can either go forward or turn in another direction. The code simply takes care of this situation by making sure Boxy moves in the opposite direction or to continue moving down the passageway. Finally, if there is 1 wall (a t-junction) or 0 walls (a cross corridor), Boxy is clamped to the centre of the tile to facilitate easy movement in the maze.

When you play the game, you will know that the current scheme is not that optimised. I leave you this exercise to improve on Boxy's movement.

function pacClass::onCollision(%srcObject, %dstObject, %srcRef, %dstRef, %time, %normal, %contacts, %points)
{
   %currentTile = Layer.pickTile(%srcObject.getPosition());

   %x = getWord(%currentTile, 0);
   %y = getWord(%currentTile, 1);   
      
   //get the tiles surrounding pacman
   %upTile = $Layer.getTileCustomData(%x SPC (%y - 1));
   %downTile = $Layer.getTileCustomData (%x SPC (%y + 1));
   %leftTile = $Layer.getTileCustomData ((%x - 1) SPC %y);
   %rightTile = $Layer.getTileCustomData((%x + 1) SPC %y);
    
   //check if there is a dot to gobble in front of pacman
   //if so, there is obviously no collision here, return
   
   //and if it's a superdot, make the 4 ghosts scatter
   switch$($pac.direction)
   {
      case $up: 
         if (%upTile $= "dot") 
         {
            Layer.clearTile(%x, %y - 1);
            $sc = $sc + 10;
            return;
         }
         if (%upTile $= "superdot")
         {
            Layer.clearTile(%x, %y - 1);
            $sc = $sc + 100;
            $ghostCounter = 0;
            $scatterghosts = true;
            return;
         }
      case $down:
         if (%downTile $= "dot") 
         {
            Layer.clearTile(%x, %y + 1);
            $sc = $sc + 10;
            return;
         }
         if (%downTile $= "superdot")
         {
            Layer.clearTile(%x, %y + 1);
            $sc = $sc + 100;            
            $ghostCounter = 0;
            $scatterghosts = true;
            return;
         }         
      case $left: 
         if (%leftTile $= "dot") 
         {
            Layer.clearTile(%x - 1,%y);
            $sc = $sc + 10;
            return;
         }
         if (%leftTile $= "superdot")
         {
            Layer.clearTile(%x - 1, %y);
            $sc = $sc + 100;            
            $ghostCounter = 0;
            $scatterghosts = true;
            return;
         }         
      case $right: 
         if (%rightTile $= "dot") 
         {
            Layer.clearTile(%x + 1, %y);
            $sc = $sc + 10;
            return;
         }
         if (%rightTile $= "superdot")
         {
            Layer.clearTile(%x + 1, %y);
            $sc = $sc + 100;            
            $ghostCounter = 0;
            $scatterghosts = true;
            return;
         }         
   }

   //count how many walls surround pacman
   %numofwalls = 0;
   if (%upTile == 10) %numofwalls++;
   if (%downTile == 10) %numofwalls++;
   if (%leftTile == 10) %numofwalls++;
   if (%rightTile == 10) %numofwalls++;
      
   //3 walls surround pacman
   //pacman's in a cul-de-sac! a dead end! 
   //get pacman to head back the way he came
   if (%numofwalls == 3)
   {
      switch$($pac.direction)
      {
         case $up: $pac.direction = $down;
         case $down: $pac.direction = $up;
         case $left: $pac.direction = $right;
         case $right: $pac.direction = $left;
      }
   }
   
   //2 walls surround pacman
   //pacman is in a passageway! 
   //continue heading down the passageway
   if (%numofwalls == 2)
   {
      switch$($pac.direction)
      {
         case $up:
            if ((%upTile $= 10) && (%downTile $= 10))
            {
               $pac.direction = $pac.previous;
               return;
            }
            if (%leftTile $= 10) $pac.direction = $right;
            if (%rightTile $= 10) $pac.direction = $left;
         case $down:
            if ((%upTile $= 10) && (%downTile $= 10))
            {
               $pac.direction = $pac.previous;
               return;
            }             
            if (%leftTile $= 10) $pac.direction = $right;
            if (%rightTile $= 10) $pac.direction = $left;
         case $left:
            if ((%leftTile $= 10) && (%rightTile $= 10)) 
            {
               $pac.direction = $pac.previous;
               return;
            }                
            if (%upTile $= 10) $pac.direction = $down;
            if (%downTile $= 10) $pac.direction = $up;
         case $right:
            if ((%leftTile $= 10) && (%rightTile $= 10))
            {
               $pac.direction = $pac.previous;
               return;
            }         
            if (%upTile $= 10) $pac.direction = $down;
            if (%downTile $= 10) $pac.direction = $up;
      }
   }
   
   %pacpos = gridLayerClass::getTileWorldPosition(Layer, %x, %y);

   //next, pacman is either at a T-junction (== 1) or a crossroads (== 0)
   //pacman has also collided because he is not in the centre of the tile
   //let's clamp pacman to the tile's centre and force him to move that direction
   if ((%numofwalls == 1) || (%numofwalls == 0))
   {  
      switch$($pac.direction)
      {
         case $up:
            if (%upTile $= 10)
               return;
            %srcObject.setPosition(%pacpos);
            $pac.direction = $up;
         case $down:
            if (%downTile $= 10)
               return;
            %srcObject.setPosition(%pacpos);
            $pac.direction = $down;
         case $left:
            if (%leftTile $= 10)
               return;
            %srcObject.setPosition(%pacpos);
            $pac.direction = $left;
         case $right:
            if (%rightTile $= 10)
               return;
            %srcObject.setPosition(%pacpos);
            $pac.direction = $right;
      }
   }
}

Part 3b - ghostclass.cs

First off, the ghosts are directed by aStar to chase Pacman around the maze. All walls have been set with a weight of 10 (impassable) to guide the ghosts around the maze.

When we first start off in the initialization section, there are three flags initialized - $scatter is set to false because the ghosts only scatter when Boxy eats a superdot, $scatterEnded is set to true because for obvious reasons, a superdot has not yet been eaten, and $followingPath is set to false because the ghost is not following any set path. There is an enableUpdateCallback for the ghostClass.

//initialise the game upon level loaded
function ghostclass::onLevelLoaded(%this)
{
   %this.scatter = false;
   %this.scatterEnded = true;
   %this.followingPath = false;
   //enables the 32msec call to pac::onUpdate
   %this.enableUpdateCallback();
}

Once every 32msecs, onUpdate will be executed. If ghosts are to scatter, let's make sure all 4 ghosts stop whatever path they were assigned before we start scattering them.

//this function is called every 32msec thereafter upon level loaded
//and it is called for every one of the four ghosts
function ghostClass::onUpdate(%this)
{
   if ($scatterGhosts)
   {
      $ghostCounter++;
      if ($ghostCounter > 3) 
         $scatterGhosts = false;
      %this.stopFollowingPath(%this);         
      %this.scatter = true;
   }
   %this.decideMovement();
   %this.updateGhostEyes();
}

In deciding their movement, the ghosts will employ the pathGrid2d class via a call to createPath. For normal movement (see the first else statement in decideMovement), you can see the ghost will use his current position (%this.getPosition) and then find the fastest path to Boxy's position (pacClass::getPos). Clyde (the tan ghost) is made to occasionally follow Blinky to throw a spanner in its AI.

Once a valid path has been decided, the followingPath flag is set to true, and the pathAStar2d class is employed through getSteps and getPathCoordsList. If you have Torsion, set a breakpoint at getPathCoordsList to watch the value of %debug. The variable shows the movement of the ghost as it travels from node to node as selected via the createPath call.

The very first if statement makes a call to ghostScatter to scatter the ghosts whenever a superdot is eaten by Boxy.

function ghostClass::decideMovement(%this)
{
   //if ghosts are vulnerable, scatter them far away from pacman   
   if (%this.scatter)
   {
      if (!isObject(%this.currentPath))
         %newPath = %this.ghostScatter(%this);
   }
   //create a valid path for the ghost to walk on
   else
      if (!isObject(%this.currentPath))
      {
         //there is a chance that clyde decides to follow blinky
         if ((%this.getName() $= "clyde") && (getRandom(1) > 0.3))
            %newPath = $pathGrid.createPath(%this.getPosition(), Blinky.getPosition(), false, true );
         else
            %newPath = $pathGrid.createPath(%this.getPosition(), pacClass::getPos(), false, true );
      }
      
   //the followingPath boolean will make the ghost follow the path all the way to the end
   if (!%this.followingPath)
   {
      %this.currentPath = %newPath;
      %this.currentPathNodeIndex = 1;
      %this.lastAStarNodeIndex = %newPath.getSteps();
      %this.moveToAStarNode(1);
      %debug = %this.currentPath.getPathCoordsList();
      %this.followingPath = true;
   }
}

Scattering the ghosts is quite a simple thing to do:

  1. Find the tile that Boxy is in.
  2. Find the x and y extents of the grid.
  3. Loop until a valid and safe place can be found (a certain number of tiles away in both x and y direction) - the while loops breaks only when the if statement with the absolute command is satisfied.
  4. Calculate the fastest path away from Boxy to the safe place via the CreatePath command.
function ghostClass::ghostScatter(%this)
{
   //prepare to scatter
   %pacpos = pacClass::getTileWorldPos();
   %pacx = getWord(%pacpos, 0);
   %pacy = getWord(%pacpos, 1);
   
   //get x and y extents of grid
   %gridSize = $pathgrid.currentTileLayer.getTileCount();
   %xSize = getWord(%gridSize, 0);
   %ySize = getWord(%gridSize, 1);

   //loops until a valid place is found to scatter to
   %foundaplace = false;
   while (!%foundaplace)
   {
      %placeX = getRandom(%xSize - 1);
      %placeY = getRandom(%ySize - 1);
      %state = $pathGrid.currentTileLayer.getTileCustomData(%placeX, %placeY);
      if (%state == 10)
         %foundaplace = false;
      else
      {
         //the place to scatter to must be far away
         //note, there is no way to break out of this loop if no legal place is found
         //so design the maze carefully
         if ((abs(%placeX - %pacx) > ((%xSize / 2) - 3)) && (abs(%placeY - %pacy) > ((%ySize / 2) - 3)))
         {
            %foundaplace = true;
            %place = Layer.getTileWorldPosition(%placeX, %placeY);
         }
      }
   }
   %hereiam = %this.getPosition();
   %this.scatter = false;
   %this.scatterEnded = false;
   return ($pathGrid.createPath(%this.getPosition(), %place, false, true));
}

The function moveToAStarNode handles ghost movement from node to node - which is handled via the moveTo method with callback set to true. Each ghost has a different speed as evidenced in the moveTo calls. For what happens next in the callback, refer to the next section.


function ghostClass::moveToAStarNode(%this, %nodeIndex)
{
   if (!isObject(%this) )
   {
      // we shouldn't be attempting to move as a ghost if we aren't a valid object
      error("ghost (" @ %this.getName() @ ") in game but not object, how?");
      return;
   }
   if (!isObject(%this.currentPath) )
   {
      // we shouldn't be attempting to move to an aStar node if we don't have a path
      error("ghost (" @ %this.getName() @ ") without a path");
      return;
   }
   // getNodeWorldCoords(%index) is a ConsoleMethod on the pathAStar2d class
   %nodeWorldCoords = %this.currentPath.getNodeWorldCoords(%nodeIndex);
   if (%nodeWorldCoords $= "InvalidNode")
   {
      //there is no code for this since all mazes will have no invalid node
      error("aStarActor (" @ %this @ ") can't find his way");

      %this.noPathExists();
   }
   else
   {
      if (%this.getName() $= "blinky")
         %this.moveTo(%nodeWorldCoords, 14, true, true, true, 0.2);
      if (%this.getName() $= "inky")
         %this.moveTo(%nodeWorldCoords, 13.5, true, true, true, 0.2);
      if (%this.getName() $= "pinky")
         %this.moveTo(%nodeWorldCoords, 13, true, true, true, 0.2);
      if (%this.getName() $= "clyde")
         %this.moveTo(%nodeWorldCoords, 12.5, true, true, true, 0.2);                  
   }
}

Once the ghost has finished moving, onPositionTarget is executed. If all nodes have been traversed, the destination has been reached. Otherwise, move on to the next node.

Once the AStar Destination is Reached, the ghost velocity is set to 0 0 to stop him in his tracks. The created path must be deleted or you will get that nasty and troublesome stack overflow problem that is every programmer's nightmare.

function ghostClass::onPositionTarget(%this)
{
   // first, check to make sure whether we have reached our destination
   if (%this.currentPathNodeIndex == %this.lastAStarNodeIndex)
   {
      %this.onAStarDestinationReached();
      return;
   }
   // if not, increment to our next node
   if (isObject(%this.currentPath))
   {
      //move to next node on the path
      %this.currentPathNodeIndex++;
      %this.moveToAStarNode(%this.currentPathNodeIndex);
   }
}


function ghostClass::onAStarDestinationReached(%this)
{
   // stop moving
   %this.setLinearVelocity("0 0");
   // need to delete the path
   if (isObject(%this.currentPath))
   {
      $pathGrid.deletePathID(%this.currentPath);
      //and flag that another path can be chosen by the ghost
      %this.followingPath = false;
      %this.scatterEnded = true;
   }
}

The final section is a cute little trick that makes the eyes follow Boxy wherever he is. Otherwise, if the ghosts are scattering, center their eyes to make them appear frightful. All this is achieved with a simple call to setLinkPoint.

//eys of ghosts are placed to look in the direction of pacman
//the linkpoint that eyes are mounted to are offset towards the direction of pacman
function ghostClass::updateGhostEyes(%this)
{
   %ghostx = %this.getPositionX();
   %ghosty = %this.getPositionY();
   %pacx = pacClass::getXPos();
   %pacy = pacClass::getYPos();
   
   if (%ghostx < %pacx) 
      %deltax = 0.1;
   else 
      if (%ghostx == %pacx)
         %deltax = 0;
      else
         if (%ghostx > %pacx)
            %deltax = -0.1;
            
   if (%ghosty < %pacy) 
      %deltay = 0.1;
   else 
      if (%ghosty == %pacy)
         %deltay = 0;
      else
         if (%ghosty > %pacy)
            %deltay = -0.1;

   if (%this.scatterEnded)
      //look in the direction of pacman
      %this.setlinkpoint(1, %deltax, %deltay);
   else
      //if pacman ate the super dot, make the ghosts cower in fear by
      //moving their eyes to the centre!
      %this.setlinkpoint(1, 0, 0);
}

Part 4 - End of the tutorial

And that's it for the tutorial! That wasn't too bad was it? If you have feedback or questions, drop me a line in the blog link found here. [3]

Finally, for future improvement considerations - Notice that I didn't implement any death routine when Boxy collides with a Ghost. That exercise is left to you to implement. It isn't that difficult to do so.

Well that's it for now. Hope you liked the tutorial!

Part 5 - Link to the Source Code and artwork

Over here, please. [4]

Return to Tutorial Hub