|
By Jonathan Knudsen, March 2003
|
|
|
Download: [SimpleGame source code]
[muTank source code]
|
|
muTank Example
|
MIDP 2.0 includes a Game API that simplifies writing 2D games. The API
is compact, comprising only five classes in the
javax.microedition.lcdui.game package. These five classes
provide two important capabilities:
-
The new
GameCanvas class makes it possible to paint
a screen and respond to input in the body of a game loop, instead of
relying on the system's paint and input threads.
-
A powerful and flexible layer API makes it easy to build complex scenes
efficiently.
Building a Game Loop with GameCanvas
GameCanvas is a Canvas with additional capabilities;
it provides methods for immediate painting and for examining the state of
the device keys. These new methods make it possible to enclose
all of a game's functionality in a single loop, under control of a
single thread. To see why this is attractive, think about how you would
implement a typical game using Canvas:
public class MicroTankCanvas
extends Canvas
implements Runnable {
public void run() {
while (true) {
// Update the game state.
repaint();
// Delay one time step.
}
}
public void paint(Graphics g) {
// Painting code goes here.
}
protected void keyPressed(int keyCode) {
// Respond to key presses here.
}
}
|
It's not a pretty picture. The run() method, which runs in
an application thread, updates the game once each time step. Typical
tasks would be to update the position of a ball or spaceship and to
animate characters or vehicles. Each time through the loop,
repaint() is called to update the screen. The system
delivers key events to keyPressed(), which updates the
game state appropriately.
The problem is that everything's in a different thread, and the game
code is confusingly spread over three different methods. When the main
animation loop in run() calls repaint(), there's no
way of knowing exactly when the system will call paint().
When the system calls keyPressed(), there's no way to know
what's going on with the other parts of the application. If your code in
keyPressed() is making updates to the game state at the
same time the screen is being rendered in paint(), the
screen may end up looking strange. If it takes longer to render the
screen than a single time step in run(), the animation may
look jerky or strange.
GameCanvas allows you to bypass the normal painting and
key-event mechanisms so that all game logic can be contained in a single
loop. First, GameCanvas allows you to access its
Graphics object directly using getGraphics(). Any
rendering on the returned Graphics object is done in an
offscreen buffer. You can then copy the buffer to the screen using
flushGraphics(), which does not return until the screen has
been updated. This approach gives you finer control than calling
repaint(). The repaint() method returns
immeditately and your application has no guarantees about exactly when
the system will call paint() to update the screen.
GameCanvas also contains a method for obtaining the current
state of the device's keys, a technique called polling. Instead
of waiting for the system to call keyPressed(), you can
determine immediately which keys are pressed by calling
GameCanvas's getKeyStates() method.
A typical game loop using GameCanvas looks like this:
public class MicroTankCanvas
extends GameCanvas
implements Runnable {
public void run() {
Graphics g = getGraphics();
while (true) {
// Update the game state.
int keyState = getKeyStates();
// Respond to key presses here.
// Painting code goes here.
flushGraphics();
// Delay one time step.
}
}
}
|
The following example demonstrates a basic game loop. It shows a
rotating X that you can move around the screen using the arrow keys.
The run() method is extremely clean, thanks to
GameCanvas.
import javax.microedition.lcdui.*;
import javax.microedition.lcdui.game.*;
public class SimpleGameCanvas
extends GameCanvas
implements Runnable {
private volatile boolean mTrucking;
private long mFrameDelay;
private int mX, mY;
private int mState;
public SimpleGameCanvas() {
super(true);
mX = getWidth() / 2;
mY = getHeight() / 2;
mState = 0;
mFrameDelay = 20;
}
public void start() {
mTrucking = true;
Thread t = new Thread(this);
t.start();
}
public void stop() { mTrucking = false; }
public void run() {
Graphics g = getGraphics();
while (mTrucking == true) {
tick();
input();
render(g);
try { Thread.sleep(mFrameDelay); }
catch (InterruptedException ie) { stop(); }
}
}
private void tick() {
mState = (mState + 1) % 20;
}
private void input() {
int keyStates = getKeyStates();
if ((keyStates & LEFT_PRESSED) != 0)
mX = Math.max(0, mX - 1);
if ((keyStates & RIGHT_PRESSED) != 0)
mX = Math.min(getWidth(), mX + 1);
if ((keyStates & UP_PRESSED) != 0)
mY = Math.max(0, mY - 1);
if ((keyStates & DOWN_PRESSED) != 0)
mY = Math.min(getHeight(), mY + 1);
}
private void render(Graphics g) {
g.setColor(0xffffff);
g.fillRect(0, 0, getWidth(), getHeight());
g.setColor(0x0000ff);
g.drawLine(mX, mY, mX - 10 + mState, mY - 10);
g.drawLine(mX, mY, mX + 10, mY - 10 + mState);
g.drawLine(mX, mY, mX + 10 - mState, mY + 10);
g.drawLine(mX, mY, mX - 10, mY + 10 - mState);
flushGraphics();
}
}
|
The example code for this article includes a MIDlet that uses this
canvas. Try running SimpleGameMIDlet to see how it works.
You'll see something like a starfish doing calisthenics (perhaps
compensating for its missing leg).
SimpleGameMIDlet Screen Shot
|
Game Scenes Are Like Onions
Typical 2D action games consist of a background and various
animated characters. Although you can paint this kind of scene yourself,
the Game API enables you to build scenes using layers. You could
make one layer a city background, and another a car. Placing the car
layer on top of the background creates a complete scene. Using the car
as a separate layer makes it easy to manipulate it independent of the
background, and of any other layers in the scene.
The Game API provides flexible support for layers with four classes:
-
Layer is the abstract parent of all layers. It defines the
basic attributes of a layer, which include a position, a size, and
whether or not the layer is visible. Each subclass of Layer
must define a paint() method to render the layer
on a Graphics drawing surface. Two concrete subclasses,
TiledLayer and Sprite, should fulfill
your 2D game desires.
-
TiledLayer is useful for creating background images. You
can use a small set of source image tiles to create large images
efficiently.
-
Sprite is an animated layer. You supply the source frames
and have full control over the animation. Sprite also
offers the ability to mirror and rotate the source frames in multiples
of 90 degrees.
-
LayerManager is a very handy class that keeps track of all
the layers in your scene. A single call to LayerManager's
paint() method is sufficient to render all of the contained
layers.
Using TiledLayer
TiledLayer is simple to understand, although it contains
some deeper nuances that are not obvious at first glance. The
fundamental idea
is that a source image provides a set of tiles that can
be arranged to
form a large scene. For example, the following image is 64 x 48 pixels.
Source Image
|
This image can be divided into 12 tiles, each 16 x 16 pixels.
TiledLayer assigns each tile a number, starting with 1 in
the upper left corner. The tiles in the source image are numbered
as follows:
Tile Numbering
|
It's simple enough to create a TiledLayer in code. You need
to specify the number of columns and rows, the source image, and the
size in pixels of the tiles in the source image. This fragment shows how to
load the image and create a TiledLayer.
Image image = Image.createImage("/board.png");
TiledLayer tiledLayer = new TiledLayer(10, 10, image, 16, 16);
|
In the example, the new TiledLayer has 10 columns and
10 rows. The tiles taken from the image are 16 pixels square.
The fun part is creating a scene using these tiles. To assign a tile to
a cell, invoke setCell(). You need to supply the column and
row number of the cell and the tile number. For example, you could
assign tile 5 to the third cell in the second row by calling
setCell(2, 1, 5). If these parameters look wrong, please
note that the tile index starts at 1, while column and row numbers
start at 0. By
default, all cells in a new TiledLayer have a tile value of
0, which means they are empty.
The following excerpt shows one way to populate a
TiledLayer, using an integer array. In a
real game, TiledLayers could be defined from resource
files, which would allow more flexibility in defining backgrounds
and enhancing the game with new boards or levels.
private TiledLayer createBoard() {
Image image = null;
try { image = Image.createImage("/board.png"); }
catch (IOException ioe) { return null; }
TiledLayer tiledLayer = new TiledLayer(10, 10, image, 16, 16);
int[] map = {
1, 1, 1, 1, 11, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 9, 0, 0, 0, 0, 0,
0, 0, 0, 0, 1, 0, 0, 0, 0, 0,
0, 0, 0, 7, 1, 0, 0, 0, 0, 0,
1, 1, 1, 1, 6, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 7, 11, 0,
0, 0, 0, 0, 0, 0, 7, 6, 0, 0,
0, 0, 0, 0, 0, 7, 6, 0, 0, 0
};
for (int i = 0; i < map.length; i++) {
int column = i % 10;
int row = (i - column) / 10;
tiledLayer.setCell(column, row, map[i]);
}
return tiledLayer;
}
|
To show this TiledLayer on the screen, you need to
pass a Graphics object to its paint() method.
TiledLayer also supports animated tiles, which makes it
easy to move a set of cells through a sequence of tiles. For more
details, see the API documentation for TiledLayer.
Using Sprites for Character Animation
The other concrete Layer provided in the Game API is
Sprite. In a way, Sprite is the conceptual
inverse of TileLayer. While TiledLayer uses a
palette of source image tiles to create a large scene,
Sprite uses a sequence of source image frames for
animation.
All you need to create a Sprite is a source image and the
size of each frame. In TiledLayer, the source image
is divided into evenly sized tiles; in Sprite, the
sub-images are called frames instead. In the following example, a source
image tank.png is used to create a Sprite with a
frame size of 32 x 32 pixels.
private MicroTankSprite createTank() {
Image image = null;
try { image = Image.createImage("/tank.png"); }
catch (IOException ioe) { return null; }
return new MicroTankSprite(image, 32, 32);
}
|
Each frame of the source image has a number, starting from 0 and
counting up. (Don't get confused here; remember that
tile numbers start at 1.) The
Sprite has a frame sequence that determines the
order in which the frames will be shown. The default frame sequence for a new
Sprite simply starts at 0 and counts up through the
available frames.
To move to the next or previous frame in the frame sequence, use
Sprite's nextFrame() and
prevFrame() methods. These methods wrap around to the
beginning or end of the frame sequence. For example, if the
Sprite is showing the last frame in its frame sequence,
calling nextFrame() will show the first frame in the frame
sequence.
To specify a frame sequence that is different from the default, pass the
sequence, represented as an integer array, to
setFrameSequence().
You can jump to a particular point in the
current frame sequence by calling
setFrame(). There is no way to jump to a specific frame
number. You can only jump to a certain point in the frame sequence.
Frame changes only become visible the next time the Sprite
is rendered, using the paint() method inherited from
Layer.
Sprite can also transform source frames. Frames may be
rotated by multiples of 90 degrees or mirrored, or a combination of
both. Constants in the Sprite class enumerate the
possibilities. The Sprite's current transformation can be
set by passing one of these constants to setTransform().
The following example mirrors the current frame around its vertical
center and rotates it by 90 degrees:
// Sprite sprite = ...
sprite.setTransform(Sprite.TRANS_MIRROR_ROT90);
|
Transformations are applied so that the Sprite's
reference pixel does not move. By default, the
reference pixel of a Sprite is located at 0, 0 in the
Sprite's coordinate space, at its upper left corner. When a
transformation is applied, the location of the reference pixel is also
transformed. The location of the Sprite is adjusted so that
the reference pixel stays in the same place.
You can change the location of the reference pixel with the
defineReferencePixel() method. For many types of animations
you will define the reference pixel to be the center of the sprite.
Finally, Sprite provides several
collidesWith() methods for detecting collisions with other
Sprites, TiledLayers, or Images.
You can detect collisions using collision rectangles (fast
but sloppy) or at the pixel level (slow but accurate). The nuances of
these methods are elusive; see the API documentation for details.
The muTank Example
The muTank example demonstrates the use of
TiledLayer, Sprite, and
LayerManager.
The important classes are MicroTankCanvas, which contains
most of the code, and MicroTankSprite, which encapsulates
the behavior of the tank.
MicroTankSprite makes extensive use of transformations.
Using a source image with only three frames,
MicroTankSprite can show the tank pointing in any
of 16 different directions. Two exposed public methods,
turn() and forward(), make the tank
easy to control.
MicroTankCanvas is a GameCanvas subclass and
contains an animation loop in run() that should look
familiar to you. The tick() method checks to see if the
tank has collided with the board. If so, its last movement is reversed
using MicroTankSprite's undo() method. The
input() method simply checks for key presses and adjusts
the direction or position of the tank accordingly. The
render() method uses a LayerManager to handle
painting. The LayerManager contains two layers,
one for the tank, one for the board.
The debug() method, called from the game loop, compares the
elapsed time through the game loop with the desired loop time (80
milliseconds) and displays the percentage of time used on the screen. It
is for diagnostic purposes only, and would be removed before the game was
shipped to customers.
The timing of the game loop is more sophisticated than in the previous
SimpleGameCanvas. To try to perform one
iteration of the game loop every 80 milliseconds accurately,
MicroTankCanvas measures the time it takes to perform
tick(), input(), and render(). It
then sleeps for the remainder of the 80-millisecond cycle,
keeping the total time through each loop as close as possible to 80
milliseconds.
Summary
MIDP 2.0's Game API provides a framework that simplifies developing 2D
action games. First, the GameCanvas class provides
painting and input methods
that make a tight game loop possible. Next, a framework of layers makes
it possible to create complex scenes. TiledLayer assembles
a large background or scene from a palette of source image tiles.
Sprite is appropriate for animated characters and can detect
collisions with other objects in the game. LayerManager is
the glue that holds layers together. The muTank example provides a
foundation of working code to demonstrate the Game API.
About the Author: Jonathan Knudsen
[e-mail]
[home page]
is the author of several books,
including
Wireless Java (second edition),
The Unofficial Guide to LEGO MINDSTORMS Robots,
Learning Java (second edition), and
Java 2D Graphics.
Jonathan has written
extensively about Java and Lego robots,
including articles for JavaWorld, EXE, NZZ Folio,
and the O'Reilly Network.
Jonathan
holds a degree in mechanical engineering from Princeton University.
|
|