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 one step. BattleTankCanvas
uses one thread to render the views and another thread to update
the game logic. Sprites
, TiledLayers
and a LayerManager
are used to draw the views.
The game engine runs inside BattleTankCanvas
. While the main thread handles all user input events, rendering
is done in a separate thread:
private void startRendering() { stopRendering(); final Graphics g = getGraphics(); renderLoop = new Timer(); renderLoop.schedule(new TimerTask() { public void run() { if(buyMenu.visible) buyMenu.render(g); else if(helpMenu.visible) helpMenu.render(g); else if(aboutMenu.visible) aboutMenu.render(g); else if(menu.visible) menu.render(g); else game.render(g); flushGraphics(); } }, 0, 1000/MAX_RENDERING_FPS); }
Updating the game logic is also done in a separate thread:
private void startGameLogic() { stopGameLogic(); gameLogicLoop = new Timer(); gameLogicLoop.schedule(new TimerTask() { public void run() { game.update(getKeyStates()); } }, 0, 1000/MAX_LOGIC_FPS); }
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) { 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); if(max < MEDIUM_THRESHOLD) { resourcePath = "/low/"; gridSizeInPixels = 4; } else if(max < HIGH_THRESHOLD) { resourcePath = "/medium/"; gridSizeInPixels = 8; } else { resourcePath = "/high/"; gridSizeInPixels = 16; } ... 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
, pointerReleased
, pointerDragged
, 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.
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:
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.
The BattleTankCanvas.createGame
method,
which is called as part of the BattleTankCanvas.showNotify
call immediately prior to BattleTankCanvas
being
made visible, either starts a new game or restores the existing game
depending on whether persisted state data is found:
protected void showNotify() { if(menu == null) createMenu(); if(game == null) createGame(); ... startRendering(); showMenu(); } ... 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.
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
MIDlet main class instantiates 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 queried and set in the menu as follows:
public static final String PURCHASE_ID = "681803"; ... public void startApp() { ... manager = getIAPManager(); manager.setIAPClientPaymentListener(this); if(trial) { manager.getProductData(PURCHASE_ID); } } ... public void productDataReceived(int status, IAPClientProductData pd) { if(status == OK) BuyMenu.setPrice(pd.getPrice()); }
PURCHASE_ID
is the product ID
for the full version purchase item in Nokia Store. The product data
is returned through the productDataReceived
method
of the IAPClientPaymentListener
interface, which
the MIDlet main class implements.
The full version is purchased
using the IAPClientPaymentManager.purchaseProduct
method,
which the MIDlet encapsulates in its own purchaseFullVersion
method:
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; }
The result of the purchase is returned and handled
through the IAPClientPaymentListener.purchaseCompleted
method:
public void purchaseCompleted(int status, String purchaseTicket) { battleTankCanvas.hideBuyMenuWaitIndicator(); if(status == OK) { setTrial(false); battleTankCanvas.hideBuyOption(); battleTankCanvas.hideBuyMenu(); } else { showAlertMessage("Purchase failure", "Purchase process failed. " + Messages.getPaymentError(status), AlertType.ERROR); } }
If the purchase is successful, the trial version mode is disabled and the FULL VERSION option is removed from the main menu.
The restoration process uses the IAPClientPaymentManager.restoreProduct
method,
which the MIDlet encapsulates in its own restoreFullVersion
method, and the IAPClientPaymentListener.restorationCompleted
method, but is otherwise virtually identical to the purchase process.
For more information, see the source code for the
MIDlet main class.
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.