Implementation

For information about the design and functionality of the MIDlet, see section Design.

For information about the key aspects of implementing the MIDlet, see:

Basic MIDlet structure

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.

Game engine

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();
    }

Game logic

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.

Destructible levels

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

Handling different screen resolutions

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

Responding to touch and key input

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.

Playing sound effects

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.

Saving and loading the state of the game

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.

In-app purchase

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);
        }
    }

Porting to the Series 40 full touch UI

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

Optimizing gameplay performance

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.

Concluding notes

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.