For information about the design and functionality of the MIDlet, see section Design.
For information about the key aspects of implementing the MIDlet, see:
The Battle Tank MIDlet consists of two
key classes: the MIDlet main class, which implements the MIDlet lifecycle
requirements and sets up the in-app purchase feature, and a GameCanvas
called BattleTankCanvas
, which runs the game engine and creates the UI. The UI has two main
views, BattleTankMenu
and Game
,
both of which implement a render
method for drawing
the view. Game
also implements an update
method for updating the game logic by two steps. BattleTankCanvas
uses a dedicated thread to update the game logic and render the
game view accordingly. Sprites
, TiledLayers
, and a LayerManager
are used for drawing the views.
The game engine runs inside BattleTankCanvas
. The main UI thread handles all user input events. The BattleTankCanvas.startGameLoop
method starts the GameThread
for updating the game
logic and rendering the game view:
private void startGameLoop() { stopGameLoop(); gameLoop = new GameThread(this, MAX_RENDERING_FPS); gameLoop.start(); }
The GameThread
is run MAX_RENDERING_FPS
times per second. The thread force-sleeps
at least one millisecond to ensure that the main thread has enough
time to handle user input events.
public class GameThread extends Thread { private boolean run = true; private final Listener listener; private final int fps; /** * Constructor * @param listener * @param fps listener.runGameLoop is called according to fps */ public GameThread(Listener listener, int fps) { if (listener == null) { throw new IllegalArgumentException("listener cannot be null"); } if (fps <= 0) { throw new IllegalArgumentException("fps must be bigger than 0"); } this.listener = listener; this.fps = fps; } /** * @see Thread#run() */ public void run() { long lastTime = 0; while (run) { long now = System.currentTimeMillis(); long delay = (1000 / fps) + lastTime - now; if (delay < 1) { delay = 1; } // force-sleep at least 1 ms to ensure time for the UI thread try { sleep(delay); } catch (InterruptedException e) { } if (run) { runGameLoop(); } lastTime = now; } } private synchronized void runGameLoop() { listener.runGameLoop(); } /** * Stop the game thread */ public synchronized void cancel() { run = false; } /** * Listener for a game thread. */ public interface Listener { /** * The game thread calls this method periodically * until the thread is cancelled. */ void runGameLoop(); } }
The GameThread.runGameLoop
method
calls the BattleTankCanvas.runGameLoop
method, which
updates the game logic and then renders the game view:
public void runGameLoop() { if (visibleMenu != null) { visibleMenu.render(graphics); } else { game.update(getKeyStates()); game.render(graphics); } flushGraphics(); }
The Game
class handles the game logic
and in-game graphics rendering for BattleTankCanvas
.
All the game objects, such as tanks, bullets, and explosions,
are implemented as Entities
. Most of the Entities
are collected to EntityManagers
, such as BulletManager
and ExplosionManager
:
bulletManager = new BulletManager(2 * numberOfTanks, resources, createBulletListener()); explosionManager = new ExplosionsManager(2 * numberOfTanks, resources); bonusManager = new BonusManager(level, resources, createBonusListener()); treeManager = new TreeManager(level, resources);
Each Entity
has a Layer
(either a Sprite
or TiledLayer
) that is added to a LayerManager
:
layerManager = new LayerManager(); treeManager.appendTo(layerManager); treeManager.refresh(); bonusManager.appendTo(layerManager); explosionManager.appendTo(layerManager); layerManager.append(base.getSprite()); layerManager.append(player.getSprite()); enemyManager.appendTo(layerManager); bulletManager.appendTo(layerManager); layerManager.append(level.getWallLayer()); layerManager.append(level.getGroundLayer());
Each Entity
knows its own state, so the state of
the game can be updated by calling the update
method
of each Entity
(either directly or by way of the EntityManager
):
public void update(int keyStates) { // update 2 steps for (int i = 0; i < 2; i++) { bulletManager.update(); bonusManager.update(); base.update(); player.update(keyStates); enemyManager.update(); } AudioManager.playEffects(); }
The Player
object takes the state
of the keys as an argument and handles user input commands. AudioManager
handles playing sound effects, so the AudioManager.playEffects
method is called as the last action
to play sound effects that might have been triggered when updating
the Entities
.
When the render
method is called, the Entities
are refreshed to
propagate all the state changes to their Layers
,
the view is centered on the player's tank, the screen is cleared, Entities
are drawn using the LayerManager
, Dialogs
are drawn if visible, and the HUD is drawn
on top of everything else on the screen:
public void render(Graphics g) { level.refresh(); bulletManager.refresh(); explosionManager.refresh(); bonusManager.refresh(); base.refresh(); player.refresh(); enemyManager.refresh(); refreshViewport(); clearScreen(g); layerManager.paint(g, 0, 0); gameOverDialog.paint(g, viewportWidth, viewportHeight); levelCompleteDialog.paint(g, viewportWidth, viewportHeight); hud.paint(g, viewportWidth, viewportHeight); }
The HUD shows the number of tanks the player has left, score points, number of enemies to be destroyed in the current level, and labels for the softkeys.
Levels are created as images where red indicates
a brick wall, gray indicates a steel wall, blue indicates water, and
so on, as defined in the Level
class. Levels can
be created using any image editor.
Levels are loaded to a two-dimensional
byte array and drawn with a TiledLayer
object. When
a Bullet
hits a wall block, the surrounding wall
blocks are destroyed by modifying the byte array, and the TiledLayer
object is updated the next time it is drawn.
Figure: Source image for a Battle Tank level
The MIDlet provides three versions of each bitmap:
Low (128x160 pixels)
Medium (240x320 pixels)
High (360x640 pixels)
When the MIDlet is started, the best version is chosen based on the screen resolution of the device, and the bitmap resources are loaded accordingly:
public class Resources { public static final int MEDIUM_THRESHOLD = 320; public static final int HIGH_THRESHOLD = 640; ... public Resources(int w, int h) { final int max = Math.max(w, h); /* * Check what is the size of the resources to be loaded */ if (max < MEDIUM_THRESHOLD) { resourcePath = "/low/"; gridSizeInPixels = 4; } else if (max < HIGH_THRESHOLD) { resourcePath = "/medium/"; gridSizeInPixels = 8; } else { resourcePath = "/high/"; gridSizeInPixels = 16; } tankMovementInPixels = gridSizeInPixels / 4; tiles = loadImage("tiles.png"); ground = loadImage("ground.png"); spawn = loadImage("spawn.png"); tank = new TankImage(loadImage("tank.png"), new int[]{0, 1, 2}, new int[]{3, 4, 5}); enemyTank = new TankImage(loadImage("FT-17_argonne.png"), new int[]{0, 1, 2}, new int[]{3, 4, 5}); fastEnemyTank = new TankImage(loadImage("m1_abrams.png"), new int[]{1, 2, 3}, new int[]{0}); heavyEnemyTank = new TankImage(loadImage("jytky_39.png"), new int[]{1, 2, 3}, new int[]{0}); bullet = loadImage("bullet.png"); base = loadImage("base.png"); explosion = loadImage("explosion.png"); ammo = loadImage("ammo.png"); clock = loadImage("clock.png"); grenade = loadImage("grenade.png"); life = loadImage("life.png"); shovel = loadImage("shovel.png"); star = loadImage("star.png"); lifeIcon = loadImage("life_icon.png"); enemyIcon = loadImage("enemy_icon.png"); hudBackground = loadImage("hud_bg.png"); trees = loadImage("trees.png"); } ... private Image loadImage(String fileName) { try { return Image.createImage(resourcePath + fileName); } catch (IOException e) { return null; } }
The following figure shows the high, medium, and low resolution versions of the same set of bitmaps.
Figure: Bitmaps for high, medium, and low resolutions
Battle Tank can be played using the touch screen or the keypad, depending on what is available on the device. When using the touch screen, swiping moves the tank to the direction of the swipe and tapping fires the tank's cannon. When using the keypad, pressing the navigation keys or the numeric keys 2, 4, 6, and 8 moves the tank to the corresponding direction and pressing the selection key or numeric key 5 fires the tank's cannon.
Pointer and key events are captured in the BattleTankCanvas
class by overriding the pointerPressed
, pointerDragged
, pointerReleased
, and keyPressed
methods inherited from the Canvas
class. The GameCanvas.getKeyStates
method is used to determine the currently pressed keys.
For more information about touch interaction, see section Touch UI.
Battle Tank has sound effects for firing and explosions. Volume is adjusted automatically relative to the distance between the sound event and the viewport, so that all events in the viewport play at full volume.
Sound effects are played with
a Player
created using the Manager.createPlayer
method:
public void load() { if (player != null) { return; } try { InputStream is = this.getClass().getResourceAsStream(file); player = Manager.createPlayer(is, "audio/mp3"); player.prefetch(); volumeControl = (VolumeControl) player.getControl("VolumeControl"); } catch (IOException e) { } catch (MediaException e) { } }
Playing is started as follows:
public void play() { if (volume > 0) { try { player.prefetch(); player.stop(); player.setMediaTime(0); volumeControl.setLevel(volume); player.start(); } catch (MediaException ex) { } } volume = 0; }
Depending on the device, there are limitations
to how many Players
can be loaded and played simultaneously
(see the Drumkit example). The MIDlet prioritizes the Players
so
that the loudest sound effects are played first.
The MIDlet uses a RecordStore
to store and persist the state
of the game:
private void createGame() { game = new Game(getWidth(), getHeight()); try { RecordStore gameState = RecordStore.openRecordStore("GameState", true); if (gameState.getNumRecords() == 0 || !game.load(gameState.getRecord(getRecordId(gameState)))) { newGame(); } gameState.closeRecordStore(); } catch (RecordStoreException e) { newGame(); } } ... public void saveGame() { if (game == null) { return; } try { RecordStore gameState = RecordStore.openRecordStore("GameState", true); if (gameState.getNumRecords() == 0) { gameState.addRecord(null, 0, 0); } byte[] data = game.getState(); gameState.setRecord(getRecordId(gameState), data, 0, data.length); gameState.closeRecordStore(); } catch (Exception e) { try { RecordStore.deleteRecordStore("GameState"); } catch (RecordStoreException rse) { } } } ... private int getRecordId(RecordStore store) throws RecordStoreException { RecordEnumeration e = store.enumerateRecords(null, null, false); try { return e.nextRecordId(); } finally { e.destroy(); } }
The state of the game is automatically saved when the MIDlet is closed:
public void destroyApp(boolean unconditional) { if (battleTankCanvas != null) { battleTankCanvas.saveGame(); } }
When the MIDlet is started again and the user selects to resume the game, the state of the game is automatically restored:
private void createGame() { game = new Game(getWidth(), getHeight()); try { RecordStore gameState = RecordStore.openRecordStore("GameState", true); if (gameState.getNumRecords() == 0 || !game.load(gameState.getRecord(getRecordId(gameState)))) { newGame(); } gameState.closeRecordStore(); } catch (RecordStoreException e) { newGame(); } }
The Game.load
method performs
the actual restoring of the existing game.
Most of the in-app purchase functionality is implemented in the MIDlet main class.
Tip: For an overview of in-app purchase, design examples, and detailed instructions on implementing in-app purchase in a MIDlet, see section In-app purchase.
The MIDlet uses a RecordStore
for storing and
persisting a boolean value that indicates whether the full version
has been purchased. This allows the MIDlet to launch correctly in
either trial or full version mode.
Tip: Another
way to determine whether the full version has been purchased would
be to try reading the DRM-protected content and catching the IOException
thrown when the content cannot be read, indicating
that the content is still locked.
Tip: Although the restoration could be done automatically behind the scenes when the MIDlet launches, the MIDlet could not rely on an open Internet connection being always available. The restoration would fail every time the device was not connected to the Internet, and the MIDlet would display a corresponding error message, resulting in a poor user experience.
The key part of the in-app purchase implementation
is the IAPClientPaymentManager
class, which provides
access to the purchase, restoration, and data retrieval methods. The IAPClientPaymentManager
is also used to forward the in-app
purchase requests to Nokia Store. The MIDlet main class instantiates
the IAPClientPaymentManager
as follows:
private static IAPClientPaymentManager manager; ... public void startApp() { ... manager = getIAPManager(); ... } ... public static IAPClientPaymentManager getIAPManager() { if (manager == null) { try { manager = IAPClientPaymentManager.getIAPClientPaymentManager(); } catch (IAPClientPaymentException ipe) { } } return manager; }
The price of the full version is retrieved by
first adding an IAPClientPaymentListener
to the IAPClientPaymentManager
as follows:
public void startApp() { ... manager = getIAPManager(); manager.setIAPClientPaymentListener(this); ... }
The IAPClientPaymentListener
interface
is used to listen to asynchronous callbacks from Nokia Store. Any
class that implements this interface must provide its own implementation
of the interface's callback methods. Battle Tank implements the following
callback methods:
productDataReceived
purchaseCompleted
restorationCompleted
Battle Tank does not implement the following callback methods, that is, it leaves them empty:
productDataListReceived
restorableProductsReceived
userAndDeviceDataReceived
When launched, if the user has not purchased the full version on the current device, Battle Tank attempts to retrieve the product metadata for the full version from Nokia Store. The metadata namely includes the price of the full version.
if (trial) { manager.getProductData(PURCHASE_ID); }
PURCHASE_ID
is the product
ID for the full version purchase item in Nokia Store. Each purchase
item has a unique 6-digit product ID that is used to identify the
purchase item. The product ID is automatically generated by Nokia
Publish when the in-app purchase item is first created. The getProductData
method attempts to connect to Nokia Store
to retrieve the metadata for the full version purchase item.
After calling the getProductData
method, the MIDlet
receives the call status and the metadata through the productDataReceived
callback method of the IAPClientPaymentListener
interface, which
the MIDlet main class implements:
public void productDataReceived(int status, IAPClientProductData pd) { if (status == OK) { BuyMenu.setPrice(pd.getPrice()); } }
The metadata is wrapped inside an IAPClientProductData
object, which holds the following information about the purchase
item:
Price
Short description
Long description
The MIDlet first checks the retrieval status, and, if the
status is OK
, fetches the price from the IAPClientProductData
object and displays it on the screen.
The full version is purchased using the IAPClientPaymentManager.purchaseProduct
method,
which the MIDlet encapsulates in its own purchaseFullVersion
method. When the user selects to buy the full version, the MIDlet
calls the purchaseFullVersion
method. The purchaseFullVersion
method, in turn, calls the purchaseProduct
method, which takes as parameters the product ID of the full version
and a restoration flag. The flag is set to automatic restoration,
meaning that the purchase request is automatically transformed to
a restoration request if restoration is available for the product.
public static boolean purchaseFullVersion() { int status = manager.purchaseProduct(PURCHASE_ID, IAPClientPaymentManager.FORCED_AUTOMATIC_RESTORATION); if (status != IAPClientPaymentManager.SUCCESS) { showAlertMessage(display, "Purchase failure", "Purchase process failed. " + Messages.getPaymentError(status), AlertType.ERROR); return false; } return true; }
If the returned status is not SUCCESS
, the MIDlet does not expect a callback from Nokia Store and displays
an error message instead. If the returned status is SUCCESS
, the in-app purchase flow is initiated. The user can then either
create a new Nokia Store account, without having to exit the MIDlet,
or they can sign in with an existing account. After the user signs
in, Nokia Store takes control of the purchase flow. For more information
about the purchase flow steps, see section Payment flow.
The
MIDlet regains control of the purchase flow through the IAPClientPaymentListener.purchaseCompleted
callback method:
If the returned status is OK
or RESTORABLE
, the trial version mode is disabled, and the MIDlet stores a value
to the RecordStore
, so that the next time the MIDlet
is launched it is launched in full version mode. The FULL VERSION option is also removed from the main menu.
If the returned status is not OK
or RESTORABLE
, the MIDlet displays an error message.
The MIDlet implements the purchaseCompleted
method as follows:
public void purchaseCompleted(int status, String purchaseTicket) { battleTankCanvas.hideBuyMenuWaitIndicator(); if (status == OK || status == RESTORABLE) { setTrial(false); battleTankCanvas.hideBuyOption(); battleTankCanvas.hideCurrentMenu(); } else { showAlertMessage("Purchase failure", "Purchase process failed. " + Messages.getPaymentError(status), AlertType.ERROR); } }
The restoration process uses the IAPClientPaymentManager.restoreProduct
method,
which the MIDlet encapsulates in its own restoreFullVersion
method, and the IAPClientPaymentListener.restorationCompleted
method. To restore a product that has been purchased with a given
Nokia Store account, the MIDlet must call the restoreProduct
method with the following parameters:
Product ID
Authentication mode
The MIDlet implements the restoreFullVersion
method as follows:
public static boolean restoreFullVersion() { int status = manager.restoreProduct(PURCHASE_ID, IAPClientPaymentManager.DEFAULT_AUTHENTICATION); if (status != IAPClientPaymentManager.SUCCESS) { showAlertMessage(display, "Restoration failure", "Restoration process failed. " + Messages.getPaymentError(status), AlertType.ERROR); return false; } return true; }
The MIDlet either prompts the user to sign in
to their Nokia Store account, or skips authentication entirely if
automatic sign-in is enabled. The MIDlet then contacts Nokia Store,
and Nokia Store takes control of the restoration flow. The MIDlet
regains control through the restorationCompleted
callback
method. If the restoration status is OK
or RESTORABLE
, the full version mode is enabled.
public void restorationCompleted(int status, String purchaseTicket) { battleTankCanvas.hideBuyMenuWaitIndicator(); if (status == OK || status == RESTORABLE) { setTrial(false); battleTankCanvas.hideBuyOption(); battleTankCanvas.hideCurrentMenu(); } else { showAlertMessage("Restoraiton failure", "Restoration failed. " + Messages.getPaymentError(status), AlertType.ERROR); } }
Battle Tank version 1.3 supports the Series 40 full touch UI. Porting Battle Tank did not require major changes. The main menu background, for example, was centered vertically, so it looks good on Series 40 full touch devices, which have a 400-pixel-high screen. The other menus with background images similarly had their images centered. The HUD was reworked, because its background image was drawn in the wrong position. Although the tiles do not fill the whole screen on a Series 40 full touch device, the HUD nicely fills the excess area.
Figure: Main menu and in-game view on a Series 40 full touch device
Battle Tank version 1.2 and earlier
run slowly on slower devices, such as Nokia Asha 306. Battle Tank
version 1.3, on the other hand, is optimized to run smoothly also
on these devices. Earlier Battle Tank versions incorporate a Timer
running rendering and game logic TimerTasks
, with Game
objects moving in snatches. Battle Tank
version 1.3 moves the rendering and game logic to a dedicated Thread
, resulting in smoother gameplay.
Battle Tank version 1.3 achieves a minor boost to the FPS rate
by "caching" the HUD. Since the drawString
methods
are quite slow to perform, the HUD is only drawn once and saved to
memory ("cached"). Drawing the saved image is much faster than drawing
the HUD from scratch. If something changes in the HUD, for example
the number of lives, the HUD is drawn again and saved to memory.
public void paint(Graphics g, int w, int h) { if (hudImgAbove == null) { hudImgAbove = initHudImage(w, h); int textOffset = (background.getHeight() - hudG.getFont().getHeight()) / 2 - 1; hudG.drawImage(resources.lifeIcon, padding, 0, Graphics.LEFT | Graphics.TOP); int x = padding + resources.lifeIcon.getWidth(); hudG.drawString(" x " + lives, x, textOffset, Graphics.LEFT | Graphics.TOP); hudG.drawString("SCORE " + score, w / 2, textOffset, Graphics.HCENTER | Graphics.TOP); hudG.drawString(" x " + enemies, w - padding, textOffset, Graphics.RIGHT | Graphics.TOP); x = w - hudG.getFont().stringWidth(" x " + enemies) - padding; hudG.drawImage(resources.enemyIcon, x, 0, Graphics.RIGHT | Graphics.TOP); } if (hudImgBelow == null) { hudImgBelow = initHudImage(w, h); hudG.drawString("MENU", w - padding, -2, Graphics.RIGHT | Graphics.TOP); if (leftButton == CONTINUE) { hudG.drawString("CONTINUE", padding, -2, Graphics.LEFT | Graphics.TOP); } else if (leftButton == NEWGAME) { hudG.drawString("NEW GAME", padding, -2, Graphics.LEFT | Graphics.TOP); } } g.drawImage(hudImgAbove, 0, 0, Graphics.LEFT | Graphics.TOP); g.drawImage(hudImgBelow, 0, h - hudImgBelow.getHeight(), Graphics.LEFT | Graphics.TOP); }
For example, when the score changes, the HUD is redrawn with the new score:
public void updateScore(int score) { this.score = score; updateHudAbove(); }
The updateHudAbove
method sets hudImgAbove
to null
, forcing the image
to be redrawn.
Battle Tank already incorporates many features, but there is always room for improvement, for example:
To improve the graphics, you could add more tiles to the levels.
To expand the gaming experience to include a social aspect, you could create a feature that allows the user to upload their high score to the Internet and there compare it with other users' high scores.
Battle Tank incorporates a very simple in-app purchase scenario
that does not need the productDataListReceived
, restorableProductsReceived
, and userAndDeviceDataReceived
methods of IAPClientPaymentListener
. Hence their
implementations are empty. To put these methods to use, you could
expand the in-app purchase scenario with, for example, individually
purchasable bonuses, enemies, and levels that the user can preview
and select from the MIDlet UI.