January 15, 2016

Android 2D RPG - Part 8: Basic Touch Handling

Hello guys! Welcome to part 8 of Android 2D RPG series, Basic Touch Handling!

For this part I'm going to change the dynamic of what we've been doing so far in the series... I'm going to explain what changes were done to the code overall, then show you the complete code for the modified classes and leave you with a link to download the source code so far, for commodity.

To make it easier for everyone, I left many comments on the code explaining the use of variables and functions, and many of the mechanics involved. If there are any doubts, you can contact me however you prefer and I'll help :)

What changed?


Well, we ended last part of the series with our hero moving from left to right over our map, but if you remember from part 1 our hero is not supposed to move, it's supposed to remain always at the centre of the screen, with the maps, NPCs and objects moving accordingly. So now our hero is "moving" (changing sprites in the direction we set it to face) while remaining an the centre. Also, the GameMap now holds the map square the hero is at, and its drawn according to it.

The most interesting part is the Touch Handling tho, the game no longer closes when the screen is touched (well, it does if the user touches the bottom part of it, which is now painted red to make it more obvious... Red -> DANGER! xD). Instead, we now let the GameMap class handle touches (it was all done by the GamePanel before), and a simple functionality was added: if the user touches on a map square (aka, touches any point inside the map) the hero is automatically transported there (we change the GameMap's x,y coordinates of the hero).

The code!


MainActivity.java
package ve.com.biocraft.biocraft;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.WindowManager;

public class MainActivity extends AppCompatActivity {

    // Constant for logging
    private static final String TAG = MainActivity.class.getSimpleName();

    private GamePanel gamePanel;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // Making App fullscreen
        getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);

        // Set our GamePanel as the View
        gamePanel = new GamePanel(this);
        setContentView(gamePanel);
        Log.d(TAG, "onCreate: View added!");
    }

    @Override
    protected void onDestroy() {
        Log.d(TAG, "onDestroy: Destroying!");
        super.onDestroy();
    }

    @Override
    protected void onStop() {
        Log.d(TAG, "onStop: Stopping!");
        super.onStop();
    }

    @Override
    protected void onPause() {
        Log.d(TAG, "onPause: Pausing!");
        gamePanel.setRunningFalse();
        super.onPause();
    }
}

GamePanel.java
package ve.com.biocraft.biocraft;

import android.app.Activity;
import android.content.Context;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.Log;
import android.view.MotionEvent;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

public class GamePanel extends SurfaceView implements SurfaceHolder.Callback {

    // Constant for logging
    private static final String TAG = GamePanel.class.getSimpleName();

    // Size of the sprites to be drawn on screen
    private static final int SPRITE_SIZE = 64;

    private MainThread thread;
    private Hero hero;
    private GameMap gameMap;

    // Paint used to paint bottom of screen red
    private Paint paint = new Paint();

    public GamePanel(Context context) {
        super(context);
        // Adding callback (this) to surface holder to catch events
        getHolder().addCallback(this);

        // Create our hero and load it's bitmap
        hero = new Hero(BitmapFactory.decodeResource(getResources(), R.drawable.lyon), SPRITE_SIZE);

        // Create our game map, load bitmap, fill buffers
        gameMap = new GameMap(BitmapFactory.decodeResource(getResources(), R.drawable.tileset),
                new int[] {56,56,56,20,19,56,56,56,
                        56,56,56,20,19,56,56,56,
                        56,56,56,20,19,56,56,56,
                        56,56,56,20,19,56,56,56,
                        56,56,56,20,19,56,56,56,
                        56,56,56,20,19,56,56,56,
                        56,56,56,20,19,56,56,56,
                        56,56,56,20,19,56,56,56,
                        56,56,56,20,19,56,56,56,
                        56,56,56,20,19,56,56,56,
                        56,56,56,20,19,56,56,56,
                        56,56,56,20,19,56,56,56}, new int[] {49,0,49,0,0,49,0,49,
                0,0,0,0,0,0,0,0,
                49,0,49,0,0,49,0,49,
                0,0,0,0,0,0,0,0,
                49,0,49,0,0,49,0,49,
                0,0,0,0,0,0,0,0,
                49,0,49,0,0,49,0,49,
                0,0,0,0,0,0,0,0,
                49,0,49,0,0,49,0,49,
                0,0,0,0,0,0,0,0,
                49,0,49,0,0,49,0,49,
                0,0,0,0,0,0,0,0}, new int[] {33,0,33,0,0,33,0,33,
                41,0,41,0,0,41,0,41,
                33,0,33,0,0,33,0,33,
                41,0,41,0,0,41,0,41,
                33,0,33,0,0,33,0,33,
                41,0,41,0,0,41,0,41,
                33,0,33,0,0,33,0,33,
                41,0,41,0,0,41,0,41,
                33,0,33,0,0,33,0,33,
                41,0,41,0,0,41,0,41,
                33,0,33,0,0,33,0,33,
                41,0,41,0,0,41,0,41}, 8, SPRITE_SIZE, 3, 11);

        // Create the Game Loop (the thread)
        thread = new MainThread(getHolder(), this);

        // Make GamePanel able to focus so it can handle events
        setFocusable(true);
    }

    @Override
    public void surfaceChanged(SurfaceHolder surfaceHolder, int format, int width, int height) {

    }

    @Override
    public void surfaceCreated(SurfaceHolder surfaceHolder) {
        // Check if its the first time the thread starts
        if (thread.getState() == Thread.State.NEW) {
            paint.setColor(Color.RED);
            hero.setX(getWidth());
            hero.setY(getHeight());
            hero.setDrawSquare();
            gameMap.setDrawCoordinates(getWidth(), getHeight());
            // When the surface is created we set the running flag to true
            thread.setRunning(true);
            // And we start the Game Loop
            thread.start();
        } else if (thread.getState() == Thread.State.TERMINATED) {
            // Start the thread again after a pause
            thread = new MainThread(getHolder(), this);
            thread.setRunning(true);
            thread.start();
        }
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder surfaceHolder) {
        Log.d(TAG, "surfaceDestroyed: Surface is being destroyed!!!");
        // Clean shutdown
        boolean retry = true;
        while (retry) {
            try {
                thread.join();
                retry = false;
            } catch (InterruptedException e) {
                // Try again to shut down the thread
            }
        }
        Log.d(TAG, "surfaceDestroyed: Thread was shut down cleanly.");
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // Detect a touch event
        if (event.getAction() == MotionEvent.ACTION_DOWN) {
            // End game if lower part of screen (64px) is touched
            if (event.getY() > getHeight() - 64) {
                thread.setRunning(false);
                ((Activity) getContext()).finish();
            } else {
                // Log touch coordinates
                Log.d(TAG, "onTouchEvent: Coordinate: x=" + event.getX() + ", y=" + event.getY());
                // Let GameMap handle the touch event
                gameMap.handleActionDown((int) event.getX(), (int) event.getY());
            }
        }
        return super.onTouchEvent(event);
    }

    @Override
    protected void onDraw(Canvas canvas) {

    }

    public void render(Canvas canvas) {
        // Fill screen with black
        canvas.drawColor(Color.BLACK);
        // Draw background buffer
        gameMap.renderBottom(canvas);
        // Draw hero
        hero.draw(canvas);
        // Draw top buffer
        gameMap.renderTop(canvas);
        // Fill bottom 64px of screen with red (for closing game)
        canvas.drawRect(0, canvas.getHeight() - 64, canvas.getWidth(), canvas.getHeight(), paint);
    }

    public void setRunningFalse() {
        thread.setRunning(false);
    }

    public void update() {
        // Update hero
        hero.update();
        // Update map
        gameMap.update();
    }
}

MainThread.java
package ve.com.biocraft.biocraft;

import android.graphics.Canvas;
import android.util.Log;
import android.view.SurfaceHolder;

public class MainThread extends Thread {

    // Constant for logging
    private static final String TAG = MainThread.class.getSimpleName();

    // Frame Period
    private static final long FRAME_PERIOD = 1000 / 25;

    private SurfaceHolder surfaceHolder;
    private GamePanel gamePanel;

    // This is the flag to check if the game is running
    private boolean running;

    // This is the void that sets the flag to either true or false
    public void setRunning(boolean running) {
        this.running = running;
    }

    // Thread constructor
    public MainThread(SurfaceHolder surfaceHolder, GamePanel gamePanel) {
        super();
        this.surfaceHolder = surfaceHolder;
        this.gamePanel = gamePanel;
    }

    @Override
    public void run() {
        Canvas canvas;
        Log.d(TAG, "run: Starting Game Loop");

        long loopTime = FRAME_PERIOD;
        long beginTime;
        long sleepTime;
        int framesSkipped;

        while (running) {
            beginTime = System.currentTimeMillis();
            // Update Game State
            this.gamePanel.update();
            canvas = null;
            // Try to lock the canvas for pixel editing
            try {
                canvas = this.surfaceHolder.lockCanvas();
                synchronized (surfaceHolder) {
                    // Render
                    this.gamePanel.render(canvas);
                }
            } finally {
                // Catching exceptions
                if (canvas != null) {
                    surfaceHolder.unlockCanvasAndPost(canvas);
                }
            }
            // loopTime is the amount of ms we want each frame to take
            sleepTime = loopTime - System.currentTimeMillis() + beginTime;
            // If we got some time to sleep, everything is ok
            if (sleepTime >= 0) {
                // Set loopTime to expected Frame Period
                loopTime = FRAME_PERIOD;
                // Send the thread to sleep for a short period
                // Good for battery saving
                try {
                    Thread.sleep(sleepTime);
                } catch (InterruptedException e) {
                    Log.d(TAG, "run: xception when attempting to sleep... :(");
                }
            } else {
                // If we're over our frame period we need to catch up
                // We'll update Game State without rendering
                // Until we get to a loopTime of, at least, the Frame Period
                framesSkipped = 0;
                loopTime = FRAME_PERIOD + sleepTime;
                while (loopTime < FRAME_PERIOD) {
                    beginTime = System.currentTimeMillis();
                    this.gamePanel.update();
                    framesSkipped++;
                    loopTime += FRAME_PERIOD - System.currentTimeMillis() + beginTime;
                }
                Log.d(TAG, "run: Frames skipped: " + framesSkipped);
            }
        }
    }
}

Hero.java
package ve.com.biocraft.biocraft;

import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.util.Log;

public class Hero {

    // Constant for logging
    private static String TAG = Hero.class.getSimpleName();

    private Bitmap bitmap;     // The image of our character sprites
    private int square;        // Size of screen square
    private int x;             // The x coordinate of our character
    private int y;             // The y coordinate of our character
    private int direction;     // The direction the character is facing (0 down, 1 left, 2 right, 3 up. The order of our sprite image)
    private int sprite;        // The animation sprite, since our characters have 3 sprites per animation, this ranges from 0 to 2
    private int spriteHeight;  // Size (in pixels) of a single sprite height
    private int spriteWidth;   // Size (in pixels) of a single sprite width
    private Rect src;          // Square of the image to be drawn
    private Rect dst;          // Square of the screen to draw unto

    // Hero constructor
    public Hero(Bitmap bitmap, int square) {
        this.bitmap = bitmap;
        this.square = square;
        this.direction = 0;
        this.sprite = 0;
        this.spriteHeight = bitmap.getHeight() / 4; // 4 directions
        this.spriteWidth = bitmap.getWidth() / 3;   // 3 sprites per direction
        this.src = new Rect(0, 0, spriteWidth, spriteHeight);
        Log.d(TAG, "Hero: Hero created!");
    }

    // Used to get the image our hero is using to draw itself (currently unused)
    public Bitmap getBitmap() {
        return bitmap;
    }

    // Used to change the image our hero is using to draw itself (currently unused)
    public void setBitmap(Bitmap bitmap) {
        this.bitmap = bitmap;
    }

    // Used to get hero's y coordinate for drawing (currently unused)
    public int getX() {
        return x;
    }

    // Used to change hero's x coordinate for drawing
    public void setX(int x) {
        this.x = (x - square) / 2;
    }

    // Used to get hero's y coordinate for drawing (currently unused)
    public int getY() {
        return y;
    }

    // Used to change hero's y coordinate for drawing
    public void setY(int y) {
        this.y = (y - square) / 2;
    }

    // Used to get hero's facing direction (currently unused)
    public int getDirection() {
        return direction;
    }

    // Used to change hero's facing direction (currently unused)
    public void setDirection(int direction) {
        this.direction = direction;
    }

    // Sets the coordinates to draw our hero at
    // Needs to be called after our GamePanel is created
    public void setDrawSquare() {
        this.dst = new Rect(this.x, this.y, this.x + square, this.y + square);
    }

    // Draws the hero on screen
    public void draw(Canvas canvas) {
        canvas.drawBitmap(bitmap, src, dst, null);
    }

    // Update hero state on each game loop
    public void update() {
        /*
        * At the moment, this changes the hero's sprite to be drawn
         */
        sprite++;
        src.left += spriteWidth;
        src.right += spriteWidth;
        if (sprite > 2) {
            sprite = 0;
            src.left = 0;
            src.right = spriteWidth;
        }
    }

}

GameMap.java
package ve.com.biocraft.biocraft;

import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.util.Log;

public class GameMap {

    // Constant for logging
    private static final String TAG = GameMap.class.getSimpleName();

    // Number of sprites per row of our bitmap
    // Depends on the tileset you're using
    private static final int TILESET_COLS = 8;

    private Bitmap bitmap;      // The image of our map's tiles
    private int[] terrain;      // Array of terrain for background buffer
    private int[] object;       // Array of objects for background buffer (hero can't move through this)
    private int[] roof;         // Array of elements for the top buffer
    private int width;          // Numbers of squares in a map row
    private int square;         // Screen square size
    private Bitmap buffer;      // Buffer for background elements
    private Bitmap roofBuffer;  // Buffer for top elements (covers bottom, hero, NPCs, etc)
    private int x;              // Hero's x coordinate
    private int y;              // Hero's y coordinate
    private int drawX;          // x coordinate to draw map
    private int drawY;          // y coordinate to draw map

    // GameMap constructor
    public GameMap(Bitmap bitmap, int[] terrain, int[] object, int[] roof, int width, int square, int x, int y) {
        this.bitmap = bitmap;
        this.terrain = terrain;
        this.object = object;
        this.roof = roof;
        this.width = width;
        this.square = square;
        this.x = x;
        this.y = y;
        fillBuffers();
        Log.d(TAG, "GameMap: Map ready!");
    }

    private void fillBuffers() {
        // We create 2 image buffers to be drawn on each render
        // One for background elements (buffer)
        // Another for top elements (roofBuffer)
        buffer = Bitmap.createBitmap(width * square, (terrain.length / width) * square, Bitmap.Config.ARGB_8888);
        roofBuffer = Bitmap.createBitmap(width * square, (terrain.length / width) * square, Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(buffer);
        Canvas roofCanvas = new Canvas(roofBuffer);
        // Draw each array on the corresponding buffer
        // Easy to expand if there's need to add more layers
        draw(terrain, canvas);
        draw(object, canvas);
        draw(roof, roofCanvas);
    }

    // Draws a layer on a buffer's canvas
    private void draw(int[] ints, Canvas canvas) {
        // Draw an array of sprite ids into a canvas
        int spriteSize = (bitmap.getWidth() / TILESET_COLS);
        for (int i = 0; i < (ints.length / width); i++) {
            for (int j = 0; j < width; j++) {
                int sprite = ints[(i * width) + j] - 1;
                if (sprite >= 0) {
                    Rect src = new Rect((sprite % TILESET_COLS) * spriteSize,
                            (int) Math.floor(sprite / TILESET_COLS) * spriteSize,
                            (sprite % TILESET_COLS) * spriteSize + spriteSize,
                            (int) Math.floor(sprite / TILESET_COLS) * spriteSize + spriteSize);
                    Rect dst = new Rect(j * square, i * square, (j + 1) * square, (i + 1) * square);
                    canvas.drawBitmap(bitmap, src, dst, null);
                }
            }
        }
    }

    // Draws the background buffer onto the screen
    public void renderBottom(Canvas canvas) {
        canvas.drawBitmap(buffer, drawX, drawY, null);
    }

    // Draws the top buffer onto the screen
    public void renderTop(Canvas canvas) {
        canvas.drawBitmap(roofBuffer, drawX, drawY, null);
    }

    // Update map state on each game loop
    public void update() {
    }

    // Sets the initial coordinates to draw our map at
    // Needs to be called after our GamePanel is created
    public void setDrawCoordinates(int drawX, int drawY) {
        this.drawX = (drawX / 2) - (square / 2) - (x * square);
        this.drawY = (drawY / 2) - (square / 2) - (y * square);
    }

    // Lets map handle touch actions
    public void handleActionDown(int eventX, int eventY) {
        /*
        * At the moment, this moves our hero's position
        * to the touched map square (if any)
        */
        eventX = (int) Math.floor((eventX - drawX) / square);
        eventY = (int) Math.floor((eventY - drawY) / square);
        Log.d(TAG, "handleActionDown: Square: x=" + eventX + ", y=" + eventY);
        if ((eventX >= 0) && (eventY >= 0) && (eventX < width) && (eventY < terrain.length / width)) {
            drawX += (square * (x - eventX));
            drawY += (square * (y - eventY));
            x = eventX;
            y = eventY;
        } else {
            Log.d(TAG, "handleActionDown: Click out of map!");
        }
    }
}

Source from this post can be downloaded here!

Hope you guys enjoyed this part of the series, in the next part we'll be finally getting our hands dirty with pathfinding! See you, take care, and share! :)

No comments: