Expanding on your knowledge of sprites, this chapter focuses on developing a Java two-dimensional game engine. Animating game objects is not all that different from animating sprite characters. A few advanced motion tricks are needed, but the transition is not too difficult. The game engine will be used as the basis for the arcade standard, Asteroids. In practice, any 2D action game could be written using the techniques you'll learn in this chapter.
Two-dimensional games require a technique embodied in early Atari personal computers, namely, player-missile graphics. Essentially, all screen objects need to know their position and if they are colliding with another entity. Beyond this, objects need to be able to move around and to rotate.
For simplicity, this chapter exploits a class in the AWT for representing objects on the screen. The Polygon class is a wonderful class for manipulating a variety of two-dimensional shapes. Polygon objects can have virtually an unlimited number of vertices and so can represent everything from simple squares to a complex space station.
Unfortunately, the Polygon class does not contain such 2D basics as rotation, scaling, and translation. The Polygon class is used only for displaying an object; a separate class is used to represent and track objects around the screen. In order to write this class, some two-dimensional basics must first be covered.
Given an ordered set of two-dimensional points, how can it be grown or shrunk? To start with, define how your points are stored. Because the Polygon class is used for display, it makes sense to store two separate arrays of points, one for the x coordinate and one for the y coordinate. To scale such a set, simply multiply the scaling factor by each vertex. The following code snippet makes a given polygon twice as large:
for ( int v = 0; v < numVertices; v++ )
{
xpoints[v] *= 2;
ypoints[v] *= 2;
}
This code works to both expand (scale > 1) and contract (scale < 1) any polygon. To scale in only the x or y direction, multiply only one of the coordinate arrays.
Object translation has nothing to do with foreign languages. Translation refers to moving an object without changing its orientation or size. The operation is identical to scaling, except instead of multiplication, addition (or subtraction) is used. The following code snippet translates an object in x-y space:
for ( int v = 0; v < numVertices; v++ )
{
xpoints[v] += 2;
ypoints[v] += 2;
}
Nothing precludes you from using different additives for the x and y coordinates. In fact, this is very common. Usually, the previous code is actually written as follows:
for ( int v = 0; v < numVertices; v++ )
{
xpoints[v] += xamount;
ypoints[v] += yamount;
}
Object rotation is considerably more complex than scaling or translation. Each point is rotated by some angle around the z-axis by using the following formula:
new_x = old_x * cos(angle) + old_y * sin(angle)
new_y = old_y * cos(angle) - old_x * sin(angle)
Positive angles rotate counter clockwise, while negative angles cause clockwise rotation.
After the rotation, the object needs to be moved back to the original coordinate space. Figure 14.1 provides a graphical view of the process.
Figure 14.1 : Rotation and translation of a polygon.
To accomplish the move, a bounding box must be computed before and after the rotation. The upper-left vertex of the bounding boxes can be subtracted to yield the x and y translation values.
The following code rotates a polygon in place. For efficiency, the cosine and sine values for the rotation angle are precomputed and stored in variables cos and sin:
for ( int v = 0; v < numVertices; v++ )
{
int old_x = xpoints[v];
int old_y = ypoints[v];
xpoints[v] = old_x * cos + old_y * sin;
ypoints[v] = old_y * cos - old_x * sin;
low_x = Math.min(low_x, xpoints[v]);
low_y = Math.min(low_y, ypoints[v]);
}
int xlate_x = min_x - low_x;
int xlate_y = min_y - low_y;
translateObject(xlate_x, xlate_y);
The requirements for the player-missile system are as follows:
Each object must implement these requirements. Because Java is object-oriented, it's easy to capture all this functionality within a base class. The Missile class contains the basis for the 2D game system.
The Missile class has two constructors. Both specify the confining rectangle for the object, but one provides a color parameter to control what color the polygon is painted in when it draws itself to the screen:
In addition to the public constructors, the Missile class contains the following public functions:
All the public and protected functions in the Missile class can be overridden in a descendant class. This is expected to happen, because descendant classes use Missile only for default behavior. Any special circumstances are handled by overriding the underlying function.
The draw() function simply paints the object onto the passed Graphics context. Painting is performed by creating an AWT Polygon and filling it with the object's color. Notice how the function simply returns if the object is dead:
public void draw(Graphics g)
{
if ( dead ) return;
int x[] = new int[dx.length];
int y[] = new int[dy.length];
for ( int v = 0; v < dx.length; v++ )
{
x[v] = (int)Math.round(dx[v]);
y[v] = (int)Math.round(dy[v]);
}
g.setColor(color);
g.fillPolygon(x, y, x.length);
}
The vertices for Missile's polygon are stored in the class as arrays of float. This is done to enable accurate shape maintenance during rotations. Rotations are performed ideally on polar coordinates. Most graphic systems, including Java, use Cartesian coordinates. The granularity of the rectangular coordinate system causes the rotated object to become distorted quickly if integers are used to store the points. Any rotations other than 90-degree increments cause the shape to become unrecognizable. floats enable the points to be manipulated so that their original shape is maintained. Before displaying the object, the points are mapped into the rectangular x-y integer space. This yields an approximation of the actual object for display.
The animate() function performs all the default movement for the object. First, a rotation is performed, provided there was a nonzero rotation angle set. Second, the object is moved according to the two velocity components, x_vel and y_vel:
public void animate()
{
if ( dead ) return;
rotateMissile();
moveMissile();
}
Collisions are easy to detect by using the AWT's Rectangle class. All the point manipulation routines within the Missile class update the bounding box for the polygon. The AWT provides a routine to check whether two Rectangles have intersected:
public boolean collide(Missile mx)
{
if ( !dead && !mx.isDead() )
return boundingBox.intersects(mx.getBoundingBox());
return false;
}
If the object is already dead, by definition it cannot collide with anything else.
The die() function is called to obliterate an object. The dead flag is set to true, and the bounding box is forced completely off the screen:
public void die()
{
dead = true;
min_x = display_w + 1;
min_y = display_h + 1;
max_x = min_x + 1;
max_y = min_y + 1;
doneMinMax();
}
The size of the bounding box is also changed to one-by-one.
The remaining methods are protected to enable only descendant classes to access them:
The setShape() function is used to set the points of the polygon:
protected void setShape(float ix[], float iy[])
{
dx = new float[ix.length];
dy = new float[iy.length];
System.arraycopy(ix, 0, dx, 0, ix.length);
System.arraycopy(iy, 0, dy, 0, iy.length);
dead = false;
}
Note |
The object is dead by default until it has a shape to render. A descendant class must call setShape() to set the points. It must not set the points directly into dx and dy or the object will remain dormant. |
The initial rotation angle is zero. The setRotationAngle() routine is used to set a new angle. In addition to calculating the sine and cosine of the angle, the direction_inc variable is set to the new angle. If the sine and cosine are set directly by a descendant class, the direction pointer is not properly oriented:
protected void setRotationAngle(double angle)
{
angle = angle * Math.PI / 180;
cos = Math.cos(angle);
sin = Math.sin(angle);
direction_inc = angle;
}
The passed angle is in degrees.
The rotateMissile() function performs a standard rotation based on the preset angle. At the end of the rotation, the direction pointer is updated to reflect the new orientation of the object:
protected void rotateMissile()
{
if ( dead ) return;
float low_x = Float.MAX_VALUE;
float low_y = Float.MAX_VALUE;
for ( int v = 0; v < dx.length; v++ )
{
double t1 = dx[v] * cos + dy[v] * sin;
double t2 = dy[v] * cos - dx[v] * sin;
dx[v] = (float)t1;
dy[v] = (float)t2;
low_x = Math.min(low_x, dx[v]);
low_y = Math.min(low_y, dy[v]);
}
float off_x = (min_x - low_x);
float off_y = (min_y - low_y);
translateMissile(off_x, off_y);
direction += direction_inc;
}
Functions scaleMissile(), translateMissile(), and rotateMissile() all adhere to the principles laid out in the beginning of this chapter. As has been mentioned previously, all these routines update the bounding box. Three functions are used to perform the update: clearMinMax(), updateMinMax(), and doneMinMax(). clear simply sets the minimum and maximum class variables to their logical extremes:
private void clearMinMax()
{
min_x = Float.MAX_VALUE;
min_y = Float.MAX_VALUE;
max_x = Float.MIN_VALUE;
max_y = Float.MIN_VALUE;
}
As each new point is generated, it is passed into updateMinMax() to see whether it contains a minimum or maximum point:
private void updateMinMax(float nx, float ny)
{
max_x = Math.max(nx, max_x);
max_y = Math.max(ny, max_y);
min_x = Math.min(nx, min_x);
min_y = Math.min(ny, min_y);
}
When all points have been generated, it can be assumed that the extremes have been located and stored. These are turned into the vertices of the bounding box:
private void doneMinMax()
{
int x = (int)Math.round(min_x);
int y = (int)Math.round(min_y);
int h = (int)Math.round(max_y) - y;
int w = (int)Math.round(max_x) - x;
boundingBox = new Rectangle(x, y, w, h);
}
The box vertices are stored as integers because the bounding box is only an approximation of the object's position. In addition, class Rectangle handles only integer inputs.
All these functions are private because, technically, a descendant class should never have to update the bounding box. There is, however, a function to enable it. Routine calculateBoundingBox() performs all three functions over the points in the polygon. It should be called if the points are ever directly manipulated in a descendant class:
protected void calculateBoundingBox()
{
clearMinMax();
for ( int v = 0; v < dx.length; v++ )
updateMinMax(dx[v], dy[v]);
doneMinMax();
}
Function moveMissile() performs movements using the object's velocity. Each point is translated by its velocity component:
protected void moveMissile()
{
bounce_x = false;
bounce_y = false;
clearMinMax();
for ( int v = 0; v < dx.length; v++ )
{
dx[v] += x_vel;
dy[v] += y_vel;
if ( dx[v] < 0 || dx[v] >= display_w )
bounce_x = true;
if ( dy[v] < 0 || dy[v] >= display_h )
bounce_y = true;
updateMinMax(dx[v], dy[v]);
}
checkBounce();
doneMinMax();
}
During the move, each point is bounds-checked to see whether it has passed the confining rectangle. If any point lies outside the confining space, a bounce flag is set. When the movement completes, a checkBounce() function is invoked. The moveMissile() function only detects a bounce possibility. It does not directly cause an object to bounce. That job is left up to the checkBounce() routine.
How is a bouncing object handled? The bounce code assumes that the collision is purely elastic. The velocity component is inverted with no loss in absolute speed. Only the direction traveled is reversed. In addition, the object is assumed to travel the full distance that its velocity would take it. This means that the object would bounce away from the wall by the same distance that it traveled past the wall. Here is the default bounce routine:
protected void checkBounce()
{
float off_x = 0;
float off_y = 0;
if ( bounce_x )
{
x_vel *= -1;
if ( min_x < 0 )
off_x = min_x;
else
off_x = max_x - display_w;
off_x *= -2;
}
if ( bounce_y )
{
y_vel *= -1;
if ( min_y < 0 )
off_y = min_y;
else
off_y = max_y - display_h;
off_y *= -2;
}
translateMissile(off_x, off_y);
}
The distance back to the wall is computed and then doubled to
yield the full distance to translate the object. Notice that the
offsets are floats. All the
coordinate components are floats
until the moment just before they are displayed.
Note |
If you do not want your objects to bounce, you should override checkBounce(). The default behavior of checkBounce() is to enable the object to bounce off the wall. |
The entire source for class Missile is on the CD-ROM; you should now be comfortable enough with it to begin using the class for a game.
This game has been around for a long time, but it's fun. It also presents a good opportunity to apply the Missile class in a real-world example.
There is a tiny spaceship floating in an asteroid field. The asteroids are moving around and the ship's job is to avoid being hit and simultaneously to use its weapons to destroy the asteroids. The ship can fire its engines to propel itself, and it can rotate a full 360 degrees. Each implementation is slightly different, but this is essentially the game. The biggest variations occur when an asteroid or the ship hits the edge of the screen. Some implementations allow the rocks to bounce, but also allow the ship to pass through to the other side of the screen. Some allow both to bounce, and some don't allow either to bounce. This implementation allows both asteroids and the ship to bounce off the screen edges.
The Asteroids applet class is the focal point for the game. It implements the Runnable interface to enable the game objects to move in a consistent, timed manner. The applet itself is responsible for painting and handling user input, and the applet's thread is responsible for moving the game objects, detecting collisions, and keeping score.
The layout of the applet is actually a good template for other
games such as Pong, Break-Out, and Space Invaders.
All these early arcade games lend themselves to a Java implementation.
At one time, these games were state of the art, but now they're
being transmitted across the Web and run on home computers! Java
is not limited to these primitive games. This game engine can
be used for 2D pinball, interactive mazes, and 2-player Internet
games. The possibilities are endless.
Tip |
Sometimes it's tempting to decompose a game further into multiple threads, but then the load time is increased to retrieve the separate class files. The Runnable interface enables one applet class file to function as two threads of execution. |
Listing 14.1 shows the full Asteroids applet class.
Listing 14.1. Asteroids applet class.
import java.applet.*;
import java.awt.*;
import java.awt.image.*;
import java.io.*;
import Missile;
public class Asteroids extends Applet
implements Runnable
{
private boolean init = false; // true after init is called
private Image offScreenImage = null; // the float buffer
private Graphics offScreen = null; // The graphics for float buffer
private Thread animation = null;
private int numRocks;
private Rock asteroids[];
private Ship ship;
private int sleepAmt;
private boolean Started = false;
private int score;
private int remainingRocks;
private boolean gameOver;
/**
* Standard initialization method for an applet
*/
public void init()
{
if ( init == false )
{
init = true;
String strSleep = getParameter("SLEEP");
if ( strSleep == null )
{
System.out.println("ERROR: SLEEP parameter is missing");
strSleep = "200";
}
sleepAmt = Integer.valueOf(strSleep).intValue();
String strNum = getParameter("ASTEROIDS");
if ( strNum == null )
{
System.out.println("ERROR: ASTEROIDS parameter is missing");
strNum = "10";
}
numRocks = Integer.valueOf(strNum).intValue();
asteroids = new Rock[numRocks];
initialize();
setBackground(Color.black);
offScreenImage = createImage(this.size().width,
this.size().height);
offScreen = offScreenImage.getGraphics();
}
}
/**
* Initialize or reinitialize a game.
* Create asteroids and ship.
*/
public void initialize()
{
for ( int a = 0; a < numRocks; a++ )
{
asteroids[a] = new Rock(this.size().width,
this.size().height);
}
ship = new Ship(this.size().width, this.size().height);
score = 100;
gameOver = false;
remainingRocks = numRocks;
Started = false;
}
/**
* Standard paint routine for an applet.
* @param g contains the Graphics class to use for painting
*/
public void paint(Graphics g)
{
offScreen.setColor(getBackground());
offScreen.fillRect(0, 0, this.size().width, this.size().height);
offScreen.setColor(Color.green);
for ( int a = 0; a < numRocks; a++ )
asteroids[a].draw(offScreen);
ship.draw(offScreen);
if ( gameOver )
{
String result = getGameOverComment();
offScreen.drawString(result, (this.size().width / 2) - 40,
(this.size().height / 2) - 10);
offScreen.drawString("Score " + score, 0, 20);
}
g.drawImage(offScreenImage, 0, 0, this);
}
/**
* Formulate an end of game ranking based on the score
*/
public String getGameOverComment()
{
int grades[] = new int[6];
int perfect = 100 + (numRocks * 10);
int amt = perfect / 5;
for ( int x = 0; x < 5; x++ )
grades[x] = (x + 1) * amt);
if ( score <= 0 )
{
score = 0;
return "Game Over - Your rank: DEAD";
}
else if ( score < grades[0] )
return "Game Over - Your rank: Ensign";
else if ( score < grades[1] )
return "Game Over - Your rank: Lieutenant";
else if ( score < grades[2] )
return "Game Over - Your rank: Commander";
else if ( score < grades[3] )
return "Game Over - Your rank: Captain";
else if ( score < grades[4] )
return "Game Over - Your rank: Admiral";
else
return "PERFECT SCORE! - Your rank: Admiral";
}
/**
* Override component's version to keep from clearing
* the screen.
*/
public void update(Graphics g)
{
paint(g);
}
/**
* Standard start method for an applet.
* Spawn the animation thread.
*/
public void start()
{
if ( animation == null )
{
animation = new Thread(this);
animation.start();
}
}
/**
* Standard stop method for an applet.
* Stop the animation thread.
*/
public void stop()
{
if ( animation != null )
{
animation.stop();
animation = null;
}
}
/**
* This applet's run method. Loop forever rolling the image
* back and forth across the screen.
*/
public void run()
{
while (true)
{
while ( !Started || gameOver ) sleep(500);
playGame();
}
}
public void playGame()
{
while (!gameOver)
{
animate();
repaint();
sleep(sleepAmt);
}
}
public void animate()
{
ship.animate();
for ( int a = 0; a < numRocks; a++ )
{
asteroids[a].animate();
if ( ship.collide(asteroids[a]) )
{
score -= 10;
if ( score == 0 ) gameOver = true;
}
if ( ship.photonsCollide(asteroids[a]) )
{
score += 10;
asteroids[a].die();
remainingRocks--;
if ( remainingRocks == 0 ) gameOver = true;
}
}
}
/**
* Handle mouse clicks
*/
public boolean mouseDown(Event evt, int x, int y)
{
if ( Started ) initialize();
Started = true;
return true;
}
/**
* Handle keyboard input
*/
public boolean keyDown(Event evt, int key)
{
switch (key)
{
case Event.LEFT: ship.leftRotation(); break;
case Event.RIGHT: ship.rightRotation(); break;
case Event.DOWN: ship.fireEngines(); break;
case 0x20: ship.firePhotons(); break;
default: break;
}
return true;
}
/**
* A simple sleep routine
* @param a the number of milliseconds to sleep
*/
private void sleep(int a)
{
try
{
Thread.currentThread().sleep(a);
}
catch (InterruptedException e)
{
}
}
}
The class has an array of asteroids and a ship variable. These descendants of Missile are discussed later in this chapter.
The init() method creates the double buffer that is used to eliminate flicker. It also calls the initialize() routine to allocate the initial screen objects. That way, when paint() is called, the applet has something to draw.
Two applet parameters are used to tune performance. Parameter SLEEP specifies how long (in milliseconds) the applet thread will sleep between updates. If this value is too short, the objects are moved without a paint. Java and Netscape "batch up" repaint requests if they come in too fast to handle. This causes objects to appear very jerky, because they are moving much further with each paint. The same effect would happen if this parameter were too long. Remember, animation is based on fooling the eyes into believing discrete movements are really smooth transitions. Two hundred seems to be an acceptable value.
The second parameter, ASTEROIDS, has dual uses. First, it enables the game to be made more difficult as the system operator sees fit. Secondly, it enables the Missile class to be tested with only one object. It is much easier to debug when the screen is not filled with distracting objects. If you change the Missile class, you may want to initially test with only one asteroid.
Why not just allocate all the objects in the init() method? Well, for one thing, you may want to allow the user to restart or, better yet, replay your game. The initialize() method provides a cleanly packaged way to set up a new game. All the screen objects are created and the game is reset. The score is also preset to 100.
A player begins the game with 100 points. Whenever an asteroid collides with the ship, the score is reduced by 10 points. Each destroyed asteroid earns 10 points. Ten hits without killing an asteroid results in game over. You allow a reasonable number of hits because the asteroids are randomly distributed around the screen. Some initial hits will happen that are beyond the control of the player. A perfect score is 100 plus 10 times the number of asteroids configured. For 20 asteroids, a perfect score is 300.
The end-of-game comment takes the score into account. The total possible range of scores is broken into seven categories. Zero and perfect are the extremes, and there are five middle ranges. Each category is assigned a different phrase.
The paint() method clears the offscreen image to black, then draws each asteroid and the ship. Actually, it asks the objects to draw themselves. Individual objects are in charge of rendering themselves (or not) in the correct location and in the proper orientation. No collision detection or scoring takes place during the paint loop. At game-end, the score and an end-of-game string is displayed. The paint() method terminates by drawing the offscreen image to the actual screen.
Mouse and keyboard handlers are installed in the applet. Mouse clicks are trapped only to start and reset the game. Four keyboard keys are trapped: Left, Right, Down, and Space. For each key, a separate ship control function is activated. Keys not of the four types are ignored.
The applet run() thread performs all the action. Because the user must click on the applet to enable it to receive the keyboard, the run() thread waits until this has happened before starting a game. In the meantime, the paint() method displays the game objects in their initial frozen state. The applet almost begs to be played.
Once the user has clicked on the applet, the run() thread passes control to playGame(). Here, the thread loops until the game is over. Each iteration through the loop animates all the objects, checks for collisions, updates the score, and then issues a repaint request. At this point, a sleep is entered for the configured number of milliseconds. To animate the objects, simply call each Missile object's animate() function.
Collisions are detected for each asteroid after it has been moved. When either the score or remainingRocks goes to zero, the game is over and the function returns to the run() method.
The asteroids are the most complicated object to set up, but the simplest to manage. Listing 14.2 shows the Rock class.
Listing 14.2. Rock class.
class Rock extends Missile
{
private static float sign = -1;
float ix[] = { 0, 8, 7, 5, 3, 1 };
float iy[] = { 0, 2, 4, 5, 4, 6 };
Rock(int dw, int dh)
{
super(dw, dh, Color.green);
// Set the shape of the asteroid
setShape(ix, iy);
// Size the asteroid
scaleMissile(2 + (Math.random() * 5));
// Set the rotation angle
setRotationAngle(Math.random() * 60);
// Set the initial position
float init_x = (float)(Math.random() * (display_w - max_x));
float init_y = (float)(Math.random() * (display_h - max_y));
translateMissile(init_x, init_y);
// Set the velocity
x_vel = (float)(1 + (Math.random() * 10));
y_vel = (float)(1 + (Math.random() * 10));
x_vel *= sign;
y_vel *= sign;
sign *= -1;
}
}
The asteroids exhibit completely default Missile class behavior. The class simply sets up the initial object conditions and then enables Missile's base functions to control it. Each asteroid begins life appearing like Figure 14.2. Then the asteroid is scaled by a random value between 2.0 and 7.0. It also is assigned a rotation angle between 0 and 60 degrees. An initial position somewhere on the screen is chosen, and finally the rock receives a random x and y velocity between 1.0 and 11.0. Although all the asteroids started out looking the same, they end up looking quite different.
Figure 14.2 : Initial asteroid.
Although the asteroids do not need to override any default Missile behavior, the Ship class in Listing 14.3 does override two functions.
Listing 14.3. Ship class.
class Ship extends Missile
{
final int MAX_VELOCITY = 20;
int rotations, engines, photons;
float speed_inc;
float ix[] = { 0, 6, 0, 2 };
float iy[] = { 0, 2, 4, 2 };
Photon activePhotons[];
double leftCos, leftSin, rightCos, rightSin;
Ship(int dw, int dh)
{
super(dw, dh, Color.red);
setShape(ix, iy);
rotations = 0;
photons = 0;
engines = 0;
direction = 0;
activePhotons = new Photon[6];
// Set the speed increments
speed_inc = 2;
// Size the ship
scaleMissile(3);
// Set the initial position
float init_x = (display_w / 2) - (2 * scale);
float init_y = (display_h / 2) - (2 * scale);
translateMissile(init_x, init_y);
}
public void leftRotation()
{
rotations++;
}
public void rightRotation()
{
rotations--;
}
public void fireEngines()
{
engines++;
}
public void firePhotons()
{
photons++;
}
public void animate()
{
float sign;
setRotationAngle(15 * rotations);
rotateMissile();
rotations = 0;
if ( engines != 0 )
{
x_vel += (float)(Math.cos(direction) * (engines * speed_inc));
y_vel -= (float)(Math.sin(direction) * (engines * speed_inc));
if ( Math.abs(x_vel) > MAX_VELOCITY )
{
if ( x_vel > 0 ) sign = 1;
else sign = -1;
x_vel = MAX_VELOCITY * sign;
}
if ( Math.abs(y_vel) > MAX_VELOCITY )
{
if ( y_vel > 0 ) sign = 1;
else sign = -1;
y_vel = MAX_VELOCITY * sign;
}
engines = 0;
}
if ( photons != 0 )
{
for ( int p = 0; p < activePhotons.length; p++ )
{
if ( activePhotons[p] == null || activePhotons[p].isDead() )
{
activePhotons[p] = new Photon(display_w, display_h,
&nbs p; direction, this);
break;
}
}
photons--;
}
moveMissile();
for ( int p = 0; p < activePhotons.length; p++ )
{
if ( activePhotons[p] != null )
{
activePhotons[p].animate();
}
}
}
public void draw(Graphics g)
{
for ( int p = 0; p < activePhotons.length; p++ )
{
if ( activePhotons[p] != null )
activePhotons[p].draw(g);
}
super.draw(g);
}
public boolean photonsCollide(Missile mx)
{
for ( int p = 0; p < activePhotons.length; p++ )
{
if ( activePhotons[p] != null )
{
if ( activePhotons[p].collide(mx) )
{
activePhotons[p].die();
return true;
}
}
}
return false;
}
}
The ship must have public methods to fire its engines and photons, to rotate left, and to rotate right. The ship must also create and track the photons that it fires. An array of photons is used to track fired projectiles. Only a limited number can be outstanding at any given time, because the code that fires a photon cannot operate until an empty slot is found.
Keyboard events happen asynchronously with respect to animate calls. For this reason, counters have been created to track how many times a particular key is pressed between calls. animate() is the first Missile function to be overridden, because the ship needs to animate its photons in addition to itself.
When animate() is called, the ship calculates the new rotation angle and sets it. If variable rotations is zero (no requests) the call to rotateMissile() does not change anything.
Next, the engines are fired. Velocity is changed based on the current orientation. The following equations derive the x and y velocity components for a given speed increase:
x_component = cos(angle) * speed;
y_component = -sin(angle) * speed;
Note |
Because the coordinate system's Y-axis is upside down, all calculations must invert the sign of the sine coefficients. |
Each component is artificially limited to an upper bound, in this case 20. The orientation is stored in the Missile class variable direction. This value is already in radians and contains the current heading of the ship. The periodic nature of sines and cosines is exploited by this variable. The value of direction is continuously increasing, going past 360 degrees after one complete rotation. Due to the periodic functionality of the trig functions, (the cosine and sine of 90 degrees is the same as the cosine and sine of 450 degrees-360 + 90), the equations function properly for the variable.
After firing the engines, the ship checks for photon requests. If present, the ship searches for a free (or dead) photon slot. If one is found, a new photon is created. In keeping with object-oriented design, the photon is in charge of its own movements.
After the ship's parameters are adjusted, the photons are animated. When all photons have been moved, the ship moves itself by calling moveMissile().
The second Missile function
to be overridden is draw().
The ship is responsible for its photons. This extends to drawing
them as well. After each photon is told to draw itself, the ship
calls its ancestor draw()
function to render itself.
Note |
There is a subtle trick going on here. The ship is drawn after the photons so that it will always be on top. The photon code cannot precisely locate the front tip of the ship, so it initializes in the center of the ship's bounding box. If the ship were drawn first, the photons would appear to emulate from the center of the ship, not from the tip. |
The final function for the Ship class is photonsCollide(). The game thread passes in each asteroid to see whether the ship has hit it. The ship doesn't really know, so it asks each of its photons whether it has collided with the rock. Any hits destroy both the photon and the asteroid.
The photon exhibits nearly default Missile behavior. The only exception is that photons don't bounce; they die when they hit a wall. Listing 14.4 describes the Photon class.
Listing 14.4. Photon class.
class Photon extends Missile
{
Missile ship;
float ix[] = { 0, 2, 2, 0 };
float iy[] = { 0, 0, 2, 2 };
Photon(float dw, float dh, double pointing, Missile firedFrom)
{
super((int)dw, (int)dh, Color.yellow);
setShape(ix, iy);
direction = pointing;
ship = firedFrom;
// Set the initial position
Rectangle shipRect = firedFrom.getBoundingBox();
float init_x = shipRect.x + (shipRect.width / 2);
float init_y = shipRect.y + (shipRect.height / 2);
translateMissile(init_x, init_y);
// Set the velocity components
x_vel = (float)(20 * Math.cos(direction));
y_vel = (float)(-20 * Math.sin(direction));
}
protected void checkBounce()
{
if ( bounce_x || bounce_y )
die();
}
}
It is not practical to locate the exact front of the ship, so the photon is initially placed in the center of the ship's bounding box. The speed is fixed at 20, but the components are derived from the ship's direction. This enables the photon to travel in exactly the direction the ship was facing when the fire request was made. When a bounce request comes in, the photon is killed.
Other than the Missile class, all the source classes are contained in the Asteroid.java source file. Compile the source and give it a try.
This implementation has a few limitations, the first of which is that photons must be rendered inside an asteroid's bounding box for it to be destroyed. Having a photon's trajectory pass through an asteroid will not work. This is the primary reason to limit a photon's speed to 20. Even at this speed, there will be times when you think a rock should have been hit, but the photon was rendered before and immediately after the bounding box of the asteroid. Figure 14.3 shows this phenomenon.
Figure 14.3 : Photon skipping over an asteroid.
The second limitation is painting from the game thread. This can make the ship's controls feel sluggish because immediate feedback on movement-and especially rotation-is delayed. Java is not very good at sending key events at a rapid pace, so this also contributes to the perceived problem.
The final limitation is the use of the bounding box itself for collision detection. There will be times when two objects are said to collide when, in reality, none of their points overlapped. Because the bounding box is an approximation of the polygon, collision detection can never be perfect.
Figure 14.4 shows the applet in action.
Figure 14.4 : Asteroids applet.
This chapter delved into two-dimensional game techniques. Scaling, translation, and rotation were introduced for an ordered set of points. The Missile class was developed to implement the basis for a multitude of two-dimensional action games. You explored advanced concepts such as velocity and bouncing. Rendering made use of AWT classes Polygon and Rectangle.
Finally, a Java Asteroid game was written to exploit the Missile class. You should now have a solid foundation in 2D game techniques. All this chapter's concepts are applicable to a wide range of Java games. Game playing is an excellent application for Java, because there is no need for permanent storage, and feedback is immediate.