Sunday, August 2, 2009

Writing the Pac-Man Game in JavaFX - Part 2

In the last article, we designed a data model and drew a maze with dots spread into the maze. Now we are ready to create the Pac-Man character. The Pac-Man character is controlled by the game player to move around the maze. While he is moving, he keeps gobbling dots along the path. To implement the Pac-Man character, we divide the coding into a few tasks so that we can create it bit by bit:

  1. Basic animation: the Pac-Man character continually open and close mouth, but he does not move
  2. Moving animation: the Pac-Man character moves inside the maze
  3. Player Controlling: the player controls the moving direction of the Pac-Man character
  4. Gobbling dots: the Pac-Man gobbles dots

Basic Animation

Let's start from the simplest thing first. We create the basic animation of the Pac-Man character. The Pac-Man character at this phase does not move but can keep opening and closing his mouth. The javafx.animation package, part of the JavaFX API, provides the easy-to-use functionality for animation. We are going to use the Timeline class to implement the animation. During an animation, properties such as speed, shape, color and location are constantly changing to achieve the desired behavior. The Timeline class allows us to update the values of animation properties along the progression of time. The Timeline.Keyframes attribute can be used to define the order of frames. We create four pictures shown below for Pac-Man's animation:

When we keep switching the above pictures(frames) of the Pac-Man character, it generates the animation effect of opening and closing the mouth. We are going to write two classes: MovingObject.fx and PacMan.fx. The MovingObject class abstracts some common attributes that we could later use to implement the Ghost class. The PacMan class extends MovingObject to display the Pac-Man character. Here is the code:

MovingObject.fx:


/*
* MovingObject.fx
*
* Created on 2009-1-1, 11:40:49
*/


package pacman;

import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import pacman.Maze;
import pacman.MazeData;

/**
* @author Henry Zhang
*/


public abstract class MovingObject {

// animation frames total and movement distance
public def ANIMATION_STEP=4;
public def MOVE_SPEED = MazeData.GRID_GAP / ANIMATION_STEP;

public def MOVING = 1;
public def STOP =0;

public def MOVE_LEFT=0;
public def MOVE_UP=1;
public def MOVE_RIGHT=2;
public def MOVE_DOWN=3;

public var maze: Maze;
public var state : Integer;

public var currentImage=0;
public var moveCounter: Integer=0;

// grid coordinates
public var x: Number;
public var y: Number;

// graphical coordinates
public var imageX: Number ;
public var imageY: Number ;

public var xDirection: Number = 0;
public var yDirection: Number = 0;

public var timeline: Timeline = createTimeline();

public function stop() {
timeline.stop();
}

public function pause() {
timeline.pause();
}

public function start() {
timeline.play();
}

// animation time line, moving the pacman
public function createTimeline(): Timeline {
Timeline {
repeatCount: Timeline.INDEFINITE
keyFrames: [
KeyFrame {
time: 250ms
action: function() {
moveOneStep();
}
}
]
}
}

public abstract function moveOneStep(): Void;
}

PacMan.fx:


/*
* PacMan.fx
*
* Created on 2009-1-1, 11:50:58
*/


package pacman;

import javafx.scene.CustomNode;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.Node;
import pacman.MazeData;

/**
* @author Henry Zhang
*/


public class PacMan extends CustomNode, MovingObject {

public var defaultImage: Image = Image {
url: "{__DIR__}images/left1.png"
};

// images for animation
def images = [
defaultImage,
Image {
url: "{__DIR__}images/left2.png"
},
defaultImage,
Image {
url: "{__DIR__}images/round.png"
}
];

// GUI image of the man
var pacmanImage : ImageView = ImageView {
x: bind imageX - 13
y: bind imageY - 13
image: bind images[currentImage]
}

postinit {
imageX = MazeData.calcGridX(x);
imageY = MazeData.calcGridX(y);

state = MOVING;
start();
}


public override function create(): Node {
return pacmanImage;
}

// handle animation of one tick
public override function moveOneStep() {

if ( state == MOVING) {

// switch to the image of the next frame
if ( currentImage < class="category1">else {
currentImage=0;
}
}
}
}

The MovingObject class defines an abstract function moveOneStep() which is called every 200 millisecond. Subclasses should implement this function to create frames of the animation. The PacMan class extends both the CustomNode and MovingObject classes. In Java, a class can implement a few interfaces. In JavaFX's grammar, there is no interface, so multiple inheritance is used here. The attribute images is a sequence containing four pictures of the animation frames. When the function moveOneStep() is invoked every 200ms, the value of the attribute pacmanImage is rotated to the next picture in the sequence. In this way, the animation of Pac-Man's opening and closing his mouth is accomplished.

Let's add in some code to the Maze class so that we can see the result of our animation. First, add a statement to create an instance of PacMan:


public class Maze extends CustomNode {

// Pac Man Character
public var pacMan : PacMan = PacMan{ maze:this x:23 y:5};

. . . .
Then in the postinit block, we put the PacMan instance into the maze:

postinit {

. . . . .

insert pacMan into group.content;
}
Now, let's run the program and you can see that the Pac-Man character keeps biting. For illustration and testing purpose, we set an interval of 200ms between frames. This is a relatively large interval and it is kind of slow for playing. We will reduce this interval a bit later as we move forward. Click on the below image to view how the program runs so far:



Animation of Pac-Man Moving

We now can make the Pac-Man character moving inside the maze. First, let me explain the purpose of a pair of variables in the MovingObject class: xDirection and yDirection. They are used to store the horizontal and vertical direction of a character. See below table:

Moving DirectionxDirection yDirection
Left-10
Right10
Up0-1
Down01

Since we have four frames for a cycle of animation, we can change the position (i.e. x or y coordinates) of the character when we update the picture of a frame. So a constant variable MOVE_SPEED, the moving speed of a character is defined in the MovingObject class and is computed as:


public def ANIMATION_STEP=4;
public def MOVE_SPEED = MazeData.GRID_GAP / ANIMATION_STEP;
During every animation clock cycle, we update the x or y coordinates of a character by a delta of MOVE_SPEED. After 4 clock cycles, the Pac-Man character moves to the next point of the grid either horizontally or vertically. Based on this algorithm, we add two functions moveHorizontally() and moveVertically() into PacMan class.

public class PacMan extends CustomNode, MovingObject {

. . . . . .

// moving horizontally
public function moveHorizontally() {

moveCounter++;

if ( moveCounter < class="category1">else {
moveCounter = 0;
x += xDirection;

imageX = MazeData.calcGridX(x);

// the X coordinate of the next point in the grid
var nextX = xDirection + x;

// check if the character hits a wall
if ( MazeData.getData(nextX, y) == MazeData.BLOCK ) {
state = STOP;
}
}
}

// moving vertically
public function moveVertically() {

moveCounter++;

if ( moveCounter < class="category1">else {
moveCounter = 0;
y += yDirection;
imageY = MazeData.calcGridX(y);

// the Y coordinate of the next point in the grid
var nextY = yDirection + y;

// check if the character hits a wall
if ( MazeData.getData(x, nextY) == MazeData.BLOCK ) {
state = STOP;
}
}
}

. . . . . .
}

The two functions are similar to each other, so let's take a look at the function moveHorizontally(). When the character's position is between two points of the grid, we use this statement to move it:


imageX += xDirection * MOVE_SPEED;

When the character reaches a point, we check whether it hits a wall of the maze. If it does, we make it stop:


// check if the character hits a wall
if ( MazeData.getData(nextX, y) == MazeData.BLOCK ) {
state = STOP;
}

Now, we can write some codes to test the moving of the Pac-man character. In PacMan class, we add in the moving code in the function moveOnetStep():


// handle animation of one tick
public override function moveOneStep() {

if ( state == MOVING) {

if ( xDirection != 0 )
moveHorizontally();

if ( yDirection != 0 )
moveVertically();

// switch to the image of the next frame
if ( currentImage < class="category1">else {
currentImage=0;
}
}
}
In the postinit block, we set the initial direction of the pac-man character:

postinit {
imageX = MazeData.calcGridX(x);
imageY = MazeData.calcGridX(y);

xDirection = -1;
yDirection = 0;

state = MOVING;
start();
}

Run the program now and we can see the Pac-Man moving to the left and stop at the border of the maze.



You can try other moving directions by changing the values of xDirection and yDirection in the postinit block. Refer to the table in previous section for possible combinations. Since we have not yet handled the part to turn the Pac-man's mouth, he always faces to the left no matter which direction he moves in.

One last thing is to deal with a special case in the Pac-man's passing through the tunnel. The Pac-man character can walk into the tunnel to reach the other side of the maze. We put in some handling in function moveHorizontally() for this purpose.


public function moveHorizontally() {

moveCounter++;

if ( moveCounter < class="category1">else {
moveCounter = 0;
x += xDirection;

imageX = MazeData.calcGridX(x);

// the X coordinate of the next point in the grid
var nextX = xDirection + x;

if ( y == 14 and ( nextX <= 1 or nextX >= 28) ) {
if ( nextX < -1 and xDirection < class="category2">x = MazeData.GRID_SIZE;
imageX = MazeData.calcGridX(x);
}
else
if ( nextX > 30 and xDirection > 0) {
x = 0;
imageX = MazeData.calcGridX(x);
}
}
else // check if the character hits a wall
if ( MazeData.getData(nextX, y) == MazeData.BLOCK ) {
state = STOP;
}
}
}
In Maze.fx, we add two WallBlackRectangle objects to create the clipping effect of the Pac-man passing the tunnel.

postinit {

. . . . .

insert pacMan into group.content;
insert WallBlackRectangle{ x1:-3, y1:13, x2:0, y2:15} into group.content;
insert WallBlackRectangle{ x1:29, y1:13, x2:31, y2:15} into group.content;
}

Then we can test this code by placing the Pac-Man character at the position (5,14):


public var pacMan : PacMan = PacMan{ maze:this x:5 y:14 };
So far, we completed the moving part of the Pac-man character.

Player's Controlling

Now, we start to work on the player's keyboard controlling. In PacMan class, we define two attributes:


// buffer to keep the keyboard input
var keyboardBuffer: Integer = -1;

// current direction of Pacman
var currentDirection: Integer = MOVE_LEFT;

The keyboardBuffer attribute is used to store the keyboard input(keypress event). It will be consumed when the pac-man character's position is valid for the turn. Buffered keyboard input can be overwritten by subsequent keypressed event. For this reason, an experienced player usually presses a key well before the Pac-man reaches a turning point.

The currentDirection is an attribute to determine which direction the pac-man faces to.

In PacMan.fx, we create a few functions as below. The function moveRight(), moveLeft(), moveUp() and moveDown() are to change the direction of the Pac-Man character based on keyboard events.


// turn pac-man to the right
public function moveRight(): Void {

if ( currentDirection == MOVE_RIGHT ) return;

var nextX = x + 1;

if ( nextX >= MazeData.GRID_SIZE) return;

if ( MazeData.getData(nextX, y) == MazeData.BLOCK ) return;

xDirection = 1;
yDirection = 0;

keyboardBuffer = -1;
currentDirection = MOVE_RIGHT;

state = MOVING;
}

// turn pac-man to the left
public function moveLeft(): Void {

if ( currentDirection == MOVE_LEFT ) return;

var nextX = x - 1;

if ( nextX <= 1) return;

if ( MazeData.getData(nextX, y) == MazeData.BLOCK ) return;

xDirection = -1;
yDirection = 0;

keyboardBuffer = -1;
currentDirection = MOVE_LEFT;

state = MOVING;
}

// turn pac-man going up
public function moveUp(): Void {

if ( currentDirection == MOVE_UP ) return;

var nextY = y - 1;

if ( nextY <= 1) return;

if ( MazeData.getData(x,nextY) == MazeData.BLOCK ) return;

xDirection = 0;
yDirection = -1;

keyboardBuffer = -1;
currentDirection = MOVE_UP;

state = MOVING;
}

// turn pac-man going down
public function moveDown(): Void {

if ( currentDirection == MOVE_DOWN ) return;

var nextY = y + 1;

if ( nextY >= MazeData.GRID_SIZE ) return;

if ( MazeData.getData(x,nextY) == MazeData.BLOCK ) return;

xDirection = 0;
yDirection = 1;

keyboardBuffer = -1;
currentDirection = MOVE_DOWN;

state = MOVING;
}

// handle keyboard input
public function handleKeyboardInput(): Void {
if ( keyboardBuffer < class="category1">return;

if ( keyboardBuffer == MOVE_LEFT )
moveLeft()
else
if ( keyboardBuffer == MOVE_RIGHT )
moveRight()
else
if ( keyboardBuffer == MOVE_UP )
moveUp()
else
if ( keyboardBuffer == MOVE_DOWN )
moveDown();
}

public function setKeyboardBuffer( k: Integer): Void {
keyboardBuffer = k;
}
In moveOneSteop() function, add in two lines of code to handle keyboard events during each tick of the animation:

public override function moveOneStep() {

// handle keyboard input only when pac-man is at a point of the grid
if ( currentImage==0 )
handleKeyboardInput();

. . . . . .
}

As we mentioned previously, the Pac-Man always faces to the left in our code. Let's modify our codes a bit to enable the Pac-Man to "turn" his mouth. A common approach is to use a separate set of pictures when the character is moving in a particular direction. However, we going to utilize the transformation feature of JavaFX to achieve this goal. Instead of switching to another set of pictures, we just rotate the picture of each frame to face to the correct direction. JavaFX makes it very simple to accomplish. Let's change some code in PacMan.


public class PacMan extends CustomNode, MovingObject {

. . . . .

// angles of rotating the images
def rotationDegree = [0, 90, 180, 270];

// GUI image of the man
var pacmanImage : ImageView = ImageView {
x: bind imageX - 13
y: bind imageY - 13
image: bind images[currentImage]
transforms: Rotate {
angle: bind rotationDegree[currentDirection]
pivotX: bind imageX
pivotY: bind imageY
}
}

. . . . .

The transforms attribute of the ImageView class allows us to apply the rotation we need. An instance of Rotate defines the angle and the center of the rotation. Binding is used again to automatically update the GUI of the character.

To accept keyboard events, we override the onKeyPressed attribute of the Maze class:


public override var onKeyPressed = function ( e: KeyEvent ) : Void {

if ( e.code == KeyCode.VK_DOWN )
pacMan.setKeyboardBuffer( pacMan.MOVE_DOWN )
else
if ( e.code == KeyCode.VK_UP )
pacMan.setKeyboardBuffer( pacMan.MOVE_UP )
else
if ( e.code == KeyCode.VK_RIGHT )
pacMan.setKeyboardBuffer( pacMan.MOVE_RIGHT )
else
if ( e.code == KeyCode.VK_LEFT )
pacMan.setKeyboardBuffer( pacMan.MOVE_LEFT );
}

To get better visual effect, we now reduce the interval of animation keyframes. In function MovingObject.createTimeline(), we change the time attribute to 50ms.


public function createTimeline(): Timeline {
Timeline {
repeatCount: Timeline.INDEFINITE
keyFrames: [
KeyFrame {
time: 50ms
action: function() {
moveOneStep();
}
}
]
}
}

Run the program and you can control the pac-man character's moving by arrow keys. Click the below button to view it online:

click to run

Gobbling Dots

The last part of the Pac-man's animation is gobbling the dots. We first create two attributes in the PacMan class: dotEatenCount and scores.


// the number of dots eaten
public var dotEatenCount : Integer = 0;

// scores of the game
public var scores: Integer = 0;

Then we write a function updateScores() to check if a dot is gobbled by the Pac-man character. The scores is updated accordingly.


public function updateScores() : Void {
if ( y != 14 or ( x > 0 and x < class="category1">var dot : Dot = MazeData.getDot( x, y ) as Dot ;

if ( dot != null and dot.visible ) {
scores += 10;
dot.visible = false;
dotEatenCount ++;
}
}
}

In function moveOneStep(), we invoke the updateScores() as below:


public override function moveOneStep() {

. . . . . .

if ( currentImage < class="category1">else {
currentImage=0;
updateScores();
}
. . . . . .

}

Finally, we add a Text instance as a scoreboard under the maze. It displays the scores as Pac-man eats the dots. The content of the Text instance is bound to pacMan.scores.


public class Maze extends CustomNode {

. . . . . .

var group : Group =
Group {
content: [

. . . . . .

Text {
font: Font {
size: 20
}
x: MazeData.calcGridX(0),
y: MazeData.calcGridY(MazeData.GRID_SIZE + 2)
content: bind "SCORES: {pacMan.scores} "
fill: Color.YELLOW
}
]
. . . . . .

By now, we completed the all the animation part of the Pac-man character. A player can control the pac-man character by keyboard and score by gobbling the dots. Click on the below button to run and play it.

click to run

Download Source Code


www.insideria.com

No comments:

Post a Comment