Previous parts in the Pac-Man series
Writing the Pac-Man Game in JavaFX - Part 1
Writing the Pac-Man Game in JavaFX - Part 2
We are now ready to create the ghosts in our game. The four ghosts, namely Blinky(red), Pinky(pink), Inky(cyan) and Clyde(orange), are trapped inside a cage when a game starts. After some time, they get out of the cage one by one and start roaming the maze. Their goal is to catch the Pac-Man. The Pac-Man dies if he is touched by one of the ghosts. If the Pac-Man swallows a magic dot, he has the power to eat ghosts for a while. During this time, the ghosts turn hollow and move more slowly.
There are two parts for writing the code for ghosts. First part is to create the animation. The second part is to implement an algorithm to control how the ghosts move inside the maze. The second part is the most interesting and crucial thing of this game. We will elaborate the algorithm in the next article. For now, we just use a simpler one for testing the animation.
Animation of Ghosts
A ghost can have three kinds of appearance. One is its normal look in its original color. The second is a hollow ghost. The third is a flashing hollow style when it is about to turn back to its original color. So we need three sets of frames for the animation. Just like the Pac-Man character, every set of frames contains 4 pictures. To make a ghost look differently, we can switch the set of frames when the status of a ghost changes. For example, below are three set of pictures for the red ghost Blinky.
In terms of moving approaches, the ghosts have three styles: roaming the maze, crawling slowly when they turn hollow, and circling in the cage. Since the first two are the same except the moving speed is different, we basically need to have two kinds of logic to handle the moving of a ghost: outside and inside the cage respectively.
When we wrote the code of the Pac-Man character, we subclassed from MovingObject. This class abstracts the common logic needed for a character. Let's write the Ghost class by extending MovingObject again. Below is the code of Ghost.fx:
/*
* Ghost.fx
*
* Created on 2009-1-28, 14:26:09
*/
package pacman;
import java.lang.Math;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
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 Ghost extends CustomNode, MovingObject{
public def TRAPPED=10;
// the pacman character
public var pacMan: PacMan;
public var hollowImage1 = Image {
url: "{__DIR__}images/ghosthollow2.png"
}
public var hollowImage2 = Image {
url: "{__DIR__}images/ghosthollow3.png"
}
public var hollowImage3 = Image {
url: "{__DIR__}images/ghosthollow1.png"
}
// images for ghosts when they become hollow
public var hollowImg =
[ hollowImage1,
hollowImage2,
hollowImage1,
hollowImage2 ];
// images for ghosts when they become hollow and flashing
public var flashHollowImg =
[ hollowImage1,
hollowImage3,
hollowImage1,
hollowImage3 ];
// time for a ghost to stay hollow
var hollowMaxTime: Integer = 80;
var hollowCounter : Integer;
// the images of animation
public var defaultImage1: Image;
public var defaultImage2: Image;
def defaultImg = [
defaultImage1,
defaultImage2,
defaultImage1,
defaultImage2,
];
// animation images
var images = defaultImg;
// initial direction and position of a ghost, used in status reset
public var initialLocationX : Number;
public var initialLocationY : Number;
public var initialDirectionX : Number;
public var initialDirectionY : Number;
// time to stay in the cage
public var trapTime: Integer;
public var trapCounter: Integer=0;
// variables to decide if ghost should chase man, and with what probability
public var changeFactor = 0.75;
// the flag is set if a ghost becomes hollow
public var isHollow: Boolean = false;
// the GUI of a ghost
var ghostNode : ImageView = ImageView {
x: bind imageX - 13
y: bind imageY - 13
image: bind images[currentImage]
}
postinit {
initialLocationX = x;
initialLocationY = y;
initialDirectionX = xDirection;
initialDirectionY = yDirection;
resetStatus();
}
// reset the status of a ghost and place it into the cage
public function resetStatus() {
x = initialLocationX;
y = initialLocationY;
xDirection = initialDirectionX;
yDirection = initialDirectionY;
isHollow = false;
moveCounter = 0;
trapCounter = 0;
currentImage = 0;
imageX = MazeData.calcGridX(x);
imageY = MazeData.calcGridY(y);
images = defaultImg;
state = TRAPPED;
timeline.keyFrames[0].time = 50ms;
visible = true;
start();
}
public function changeToHollowGhost() {
hollowCounter = 0;
isHollow = true;
// switch the animation images
images = hollowImg;
// make it moves slower
timeline.stop();
timeline.keyFrames[0].time = 140ms;
timeline.play();
}
// decide whether to change the current direction of a ghost
public function changeDirectionXtoY(mustChange: Boolean): Void {
if ( not mustChange and Math.random() > changeFactor ) {
return; // no change of direction
}
// will change to a Y direction if possible
var goUp = MoveDecision {
x: this.x
y: this.y - 1 };
var goDown = MoveDecision {
x: this.x
y: this.y + 1
};
// evaluate the moving choices to pick the best one
goUp.evaluate();
goDown.evaluate();
if ( goUp.score < class="category1">and goDown.score < class="category1">return; // no change of direction
var continueGo = MoveDecision {
x: this.x + xDirection
y: this.y
};
continueGo.evaluate();
if ( continueGo.score > 0 and continueGo.score > goUp.score
and continueGo.score > goDown.score ) {
return;
}
var decision = -1; // make it goes up first, then decide if we need to change it
if ( goUp.score < decision =" 1" class="category1">else
if ( goDown.score > 0 ) {
// random pick
if ( Math.random() > 0.5 )
decision = 1;
}
yDirection = decision;
xDirection = 0;
}
// decide whether to change the current direction of a ghost
public function changeDirectionYtoX(mustChange: Boolean): Void {
if ( not mustChange and Math.random() > changeFactor )
return; // no change of direction
// will change to X directions if possible
var goLeft = MoveDecision {
x: this.x - 1
y: this.y
};
var goRight = MoveDecision {
x: this.x + 1
y: this.y
};
// evaluate the moving choices to pick the best one
goLeft.evaluate();
goRight.evaluate();
if ( goLeft.score < class="category1">and goRight.score < class="category1">return; // no change of direction
}
var continueGo = MoveDecision {
x: this.x
y: this.y + yDirection
};
continueGo.evaluate();
if ( continueGo.score > 0 and continueGo.score > goLeft.score
and continueGo.score > goRight.score ) {
return;
}
// make it goes up first, then decide if we need to change it to down
var decision = -1;
if ( goLeft.score < decision =" 1" class="category1">else
if ( goRight.score > 0 ) {
// random pick
if ( Math.random() > 0.5 )
decision = 1;
}
xDirection=decision;
yDirection = 0;
}
// move the ghost horizontally
public function moveHorizontally() {
moveCounter++;
if ( moveCounter > ANIMATION_STEP - 1) {
moveCounter=0;
x += xDirection;
imageX= MazeData.calcGridX(x);
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
if (nextX < class="category1">or nextX > MazeData.GRID_SIZE) {
changeDirectionXtoY(true)
}
else
if ( MazeData.getData(nextX, y) == MazeData.BLOCK ) {
changeDirectionXtoY(true)
}
else {
changeDirectionXtoY(false);
}
}
else {
imageX += xDirection * MOVE_SPEED;
}
}
// move the ghost vertically
public function moveVertically() {
moveCounter++;
if ( moveCounter > ANIMATION_STEP - 1) {
moveCounter = 0;
y += yDirection;
imageY = MazeData.calcGridX(y);
var nextY= yDirection + y;
if ( nextY < class="category1">or nextY > MazeData.GRID_SIZE) {
changeDirectionYtoX(true);
}
else
if ( MazeData.getData(x, nextY) == MazeData.BLOCK ) {
changeDirectionYtoX(true);
}
else {
changeDirectionYtoX(false);
}
}
else {
imageY += yDirection * MOVE_SPEED;
}
}
// move the ghost horizontally in the cage
public function moveHorizontallyInCage() {
moveCounter++;
if ( moveCounter > ANIMATION_STEP - 1) {
moveCounter=0;
x += xDirection;
imageX = MazeData.calcGridX(x);
var nextX = xDirection + x;
if ( nextX < xdirection =" 0;" ydirection =" 1;" class="category1">else
if ( nextX > 17) {
xDirection = 0;
yDirection = -1;
}
}
else {
imageX += xDirection * MOVE_SPEED;
}
}
// move the ghost vertically in a cage
public function moveVerticallyInCage() {
moveCounter++;
if ( moveCounter > ANIMATION_STEP - 1) {
moveCounter=0;
y += yDirection;
imageY= MazeData.calcGridX(y) + 8;
var nextY = yDirection + y;
if ( nextY < ydirection =" 0;" xdirection =" -1;" class="category1">else
if ( nextY > 15) {
yDirection = 0;
xDirection = 1;
}
}
else {
imageY += yDirection * MOVE_SPEED;
}
}
public function hide() {
visible=false;
timeline.stop();
}
// move one tick
public override function moveOneStep() {
if ( state == MOVING or state == TRAPPED ) {
if ( xDirection != 0 ) {
if ( state == MOVING )
moveHorizontally()
else
moveHorizontallyInCage();
}
else
if ( yDirection != 0 ) {
if ( state == MOVING )
moveVertically()
else
moveVerticallyInCage();
}
if ( currentImage < class="category1">else {
currentImage=0;
if ( state == TRAPPED ) {
trapCounter++;
if ( trapCounter > trapTime and x == 14 and y == 13) {
// go out of the cage
y = 12;
xDirection = 0;
yDirection = -1;
state = MOVING;
}
}
}
}
// check to see if need to switch back to a normal status
if ( isHollow ) {
hollowCounter++;
if ( hollowCounter == hollowMaxTime - 30 )
images = flashHollowImg
else
if ( hollowCounter > hollowMaxTime ) {
isHollow = false;
images = defaultImg;
timeline.stop();
timeline.keyFrames[0].time = 50ms;
timeline.play();
}
}
}
public override function create(): Node {
return ghostNode;
}
}
The variable defaultImg is a sequence of images used as a ghost normal look. The variable hollowImg and flashHollowImg store two sets of images for the states when a ghost becomes hollow and flashing. Similar to the PacMan class, the moving logic is handled in the function moveOneStep(). When a ghost is inside the cage, the function moveHorizontallyInCage() and moveVerticallyInCage() make the ghost turning around and around inside the cage. When a ghost gets out of the cage, two functions moveHorizontally() and moveVertically() control its roaming behavior. The variable trapTime determines how long a ghost stays in the cage before it gets out. Properly choosing the values of this instance variable makes four ghosts coming out the cage in a fixed order(Blinky-Pinky-Inky-Clyde). The below code in the function moveOneStep() sets free the ghost after a pre-defined time.
public override function moveOneStep() {
. . . . . .
if ( state == TRAPPED ) {
trapCounter++;
if ( trapCounter > trapTime and x == 14 and y == 13) {
// go out of the cage
y = 12;
xDirection = 0;
yDirection = -1;
state = MOVING;
}
}
. . . . . .
}
The function changeToHollowGhost() turns a ghost into a hollow style. What it does is switching the animation pictures and slowing down the moving speed of a ghost.
public function changeToHollowGhost() {
hollowCounter = 0;
isHollow = true;
// switch the animation images
images = hollowImg;
// make it moves slower
timeline.stop();
timeline.keyFrames[0].time = 140ms;
timeline.play();
}
After a ghost becomes hollow, it resumes to its normal color after a period of time. The second half of the function moveOneStep() uses a counter to keep track of the time and flashes the ghost just before it turns into its normal color. From this part, we can see how the switching of 3 sets of pictures works.
public override function moveOneStep() {
. . . . . .
// check to see if need to switch back to a normal status
if ( isHollow ) {
hollowCounter++;
if ( hollowCounter == hollowMaxTime - 30 )
images = flashHollowImg
else
if ( hollowCounter > hollowMaxTime ) {
isHollow = false;
images = defaultImg;
timeline.stop();
timeline.keyFrames[0].time = 50ms;
timeline.play();
}
}
}
Roaming the Maze
As we mentioned previously, the algorithm that governs the ghosts' moving is the heart of this program. For the purpose of testing the ghosts' animation, for now, we apply a "random" moving algorithm, ie. the ghosts run arbitrarily inside the maze. In next article, we will implement a more complex algorithm. The function changeDirectionYtoX( Boolean ) and changeDirectionXtoY( Boolean ) give out decisions of whether a ghost should keep its current direction, or make a left or right turn. For illustration, let's take an in-depth look at the function changeDirectionYtoX( Boolean ).
// decide whether to change the current direction of a ghost
public function changeDirectionYtoX(mustChange: Boolean): Void {
if ( not mustChange and Math.random() > changeFactor )
return; // no change of direction
// will change to a X direction if possible
var goLeft = MoveDecision {
x: this.x - 1
y: this.y
};
var goRight = MoveDecision {
x: this.x + 1
y: this.y
};
// evaluate the moving choices to pick the best one
goLeft.evaluate();
goRight.evaluate();
if ( goLeft.score < class="category1">and goRight.score < class="category1">return; // no change of direction
}
var continueGo = MoveDecision {
x: this.x
y: this.y + yDirection
};
continueGo.evaluate();
if ( continueGo.score > 0 and continueGo.score > goLeft.score
and continueGo.score > goRight.score ) {
return;
}
// make it goes up first, then decide if we need to change it to down
var decision = -1;
if ( goLeft.score < decision =" 1" class="category1">else
if ( goRight.score > 0 ) {
// random pick
if ( Math.random() > 0.5 )
decision = 1;
}
xDirection=decision;
yDirection = 0;
}
When a ghost is moving vertically, this function determines the next direction. Possible decisions include: turning left, turning right, and continue with the current direction. A class MoveDecision is used to model a tentative decision. See the below code:
/*
* MoveDecision.fx
*
* Created on 2009-1-28, 14:42:00
*/
package pacman;
/**
* @author Henry Zhang
*/
public class MoveDecision {
// x and y of an intended move
public var x: Number;
public var y: Number;
public var score: Number;
// evaluate if the move is valid,
// if it is invalid, returns -1;
// if it is valid, compute its score for ranking the final decision
public function evaluate( ):Void {
if ( x < class="category1">or y < class="category1">or y >= MazeData.GRID_SIZE or x >= MazeData.GRID_SIZE){
score = -1;
return ;
}
var status = MazeData.getData(x, y);
if ( status == MazeData.BLOCK ) {
score = -1;
return ;
}
// rank it as a default score
score = 1;
}
}
The evaluate() function evaluates a moving decision and gives a score. A ghost simply picks the decision with highest ranking score. In a random moving algorithm, all decisions are given an equal score 1. If a move leads the ghost to hitting a wall, the ranking score is (-1), which automatically eliminates it from being a candidate decision. If a ghost reaches a wall, the argument mustChange of changeDirectionYtoX(Boolean) is set so that a ghost always gets a change of direction. If this argument is false, the decision to change direction is affected by a random factor changeFactor. This allows the moving behavior of a ghosts more unpredictable, hence the player cannot guess the moving pattern of a ghost. The changeDirectionXtoY(Boolean) has a similar logic and it determines the moving decision when a ghost is going horizontally.
Running the Game
Now we are ready to put things together and have some fun running the program. We add in some code to Maze.fx, putting four ghosts on stage:
public class Maze extends CustomNode {
. . . . .
public var ghostBlinky = Ghost {
defaultImage1: Image {
url: "{__DIR__}images/ghostred1.png"
}
defaultImage2: Image {
url: "{__DIR__}images/ghostred2.png"
}
maze: this
pacMan: pacMan
x: 17
y: 15
xDirection: 0
yDirection: -1
trapTime: 1
};
public var ghostPinky = Ghost {
defaultImage1:Image {
url: "{__DIR__}images/ghostpink1.png"
}
defaultImage2:Image {
url: "{__DIR__}images/ghostpink2.png"
}
maze: this
pacMan: pacMan
x: 12
y: 14
xDirection: 0
yDirection: 1
trapTime: 10
};
public var ghostInky = Ghost {
defaultImage1:Image {
url: "{__DIR__}images/ghostcyan1.png"
}
defaultImage2:Image {
url: "{__DIR__}images/ghostcyan2.png"
}
maze: this
pacMan: pacMan
x: 13
y: 15
xDirection: 1
yDirection: 0
trapTime: 40
};
public var ghostClyde = Ghost {
defaultImage1:Image {
url: "{__DIR__}images/ghostorange1.png"
}
defaultImage2:Image {
url: "{__DIR__}images/ghostorange2.png"
}
maze: this
pacMan: pacMan
x: 15
y: 14
xDirection: -1
yDirection: 0
trapTime: 60
};
public var ghosts = [ghostBlinky, ghostPinky, ghostInky, ghostClyde];
. . . . . .
postinit {
. . . . . .
insert pacMan into group.content;
insert ghosts 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;
}
Run the program and you can see four ghosts roaming the maze. You can control the Pac-Man character by keyboard to eat dots. However, the ghosts cannot eat the Pac-Man even they meet each other. We will implement this part in next article. Click on the below screenshot and see it for yourself:
www.insideria.com
No comments:
Post a Comment