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 a BattleTankCanvas, which inherits GameCanvas 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.

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.

Optimizing gameplay performance

Battle Tank version 1.2 and earlier run slowly on slower devices, such as Nokia Asha 306 on Nokia Asha software platform. 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.