Apps

The ABCs of Android game development: User input

William J. Francis concludes his Android game development series with this tutorial on how to derive input from a user and how to use the input to influence the on-screen action.

This post marks the final installment in my ABC's of Android game development series. To recap, in the first post we created a canvas and framework for our game. In the second post we introduced sprites. In the third we learned how to deal with animation, and in the fourth post we dealt with collisions.

The goal of the Android game is to adjust the flying saucer's momentum in order to avoid getting smashed to bits by the asteroid. This simple project incorporates all the essential aspects of a video game: a canvas, sprites, animation, collision detection, and user input. There are plenty of other bells and whistles that get peppered into a video game -- sound effects, explosions, and score keeping to name a few -- but I consider all of these elements digital candy. The only essential ingredient we are missing in our game is giving the player some control over the spacecraft.

Back in the 1980s when video games ruled pizza parlors and skating rinks, user input was done primarily via a joystick. There were a few exceptions (Breakout, Marble Madness, and Paperboy come to mind), but as a rule of thumb the joystick was the defacto controller for most games.

Today's generation of smartphones and tablets have blown the old model of game controls away. Half of the fun is the innovative input available to users and developers. Touch screen controls, keyboards, trackballs, and tilt-based games that make use of the built-in accelerometer on most phones are a sampling of the controls available.

For our game, the intention is not to implement complicated controls for maneuvering the player-controlled sprite; we want only to demonstrate how input can be derived from a user and how this input can be used to influence the on-screen action. Therefore, we will respond only to touch events, and in particular the ACTION_DOWN and ACTION_UP events.

User input

This tutorial builds on what we created in part four. In order to really understand what is going on, it helps to see the code in the context of the whole. Therefore, the code listing in this tutorial is our complete and final code base. The new code is commented inline to help you spot the differences between this post and its predecessor. You can follow along with the step-by-step instructions or download and import the entire project into Eclipse.

1. Create a new Android project in Eclipse. Target Android 2.1 or higher. Be sure to rename the startup activity Main.java and the corresponding layout to main.xml.

2. While there are no changes in our manifest file or layout since part four, I included both below for completeness.

AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.authorwjf.gamedevtut05"
    android:versionCode="1"
    android:versionName="1.0" >
    <uses-sdk
        android:minSdkVersion="8"
        android:targetSdkVersion="15" />
    <application
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >
        <activity
            android:name=".Main"
            android:label="@string/title_activity_main"
            android:screenOrientation="portrait"
android:configChanges="orientation|keyboardHidden">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

main.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:orientation="vertical" >
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="top|center"
        android:text="ABC's of Android Game Dev" />
    <Button
               android:id="@+id/the_button"
               android:layout_width="wrap_content"
               android:layout_height="wrap_content"
               android:layout_gravity="center"
               android:gravity="center"
               android:enabled="false"
               android:text="Reset" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="Sprite Speed (?,?)"
        android:id="@+id/the_label" />
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="Last Collision XY (?,?)"
        android:id="@+id/the_other_label" />
        <com.authorwjf.drawing.GameBoard
                   android:layout_width="fill_parent"
                   android:layout_height="fill_parent"
                   android:layout_margin="20dip"
                   android:id="@+id/the_canvas"/>
</LinearLayout>

3. As in the previous tutorials, we created a /drawable folder in the /res directory where we place our two sprite images: ufo.png and asteroid.png.  Both images have been saved on a 50x50 pixel canvas with a transparent background.

4. In the first tutorial we set up our game so the GameBoard class handled all the drawing, while the Main class was responsible for controlling the positions of our game objects. Well now we get to the power of that programming paradigm. Because the GameBoard class is self-contained and responsible only for drawing the action, not determining where the sprites go, their speed, etc., we can add our game controls (as well as quite a bit of other behavior) without touching our drawing package. Take a look for yourself -- there is no new code in the GameBoard.java class since our last tutorial.

GameBoard.java

package com.authorwjf.drawing;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

import com.authorwjf.gamedevtut05.R;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Point;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.View;

public class GameBoard extends View{

private Paint p;
       private List<Point> starField = null;
       private int starAlpha = 80;
       private int starFade = 2;
       private Rect sprite1Bounds = new Rect(0,0,0,0);
       private Rect sprite2Bounds = new Rect(0,0,0,0);
       private Point sprite1;
       private Point sprite2;
       private Bitmap bm1 = null;
       private Matrix m = null;
       private Bitmap bm2 = null;
       private boolean collisionDetected = false;
       private Point lastCollision = new Point(-1,-1);

private int sprite1Rotation = 0;

private static final int NUM_OF_STARS = 25;

synchronized public void setSprite1(int x, int y) {
             sprite1=new Point(x,y);
       }

synchronized public int getSprite1X() {
             return sprite1.x;
       }

synchronized public int getSprite1Y() {
             return sprite1.y;
       }

synchronized public void setSprite2(int x, int y) {
             sprite2=new Point(x,y);
       }

synchronized public int getSprite2X() {
             return sprite2.x;
       }

synchronized public int getSprite2Y() {
             return sprite2.y;
       }

synchronized public void resetStarField() {
             starField = null;
       }

synchronized public int getSprite1Width() {
             return sprite1Bounds.width();
       }

synchronized public int getSprite1Height() {
             return sprite1Bounds.height();
       }

synchronized public int getSprite2Width() {
             return sprite2Bounds.width();
       }

synchronized public int getSprite2Height() {
             return sprite2Bounds.height();
       }

synchronized public Point getLastCollision() {
             return lastCollision;
       }

synchronized public boolean wasCollisionDetected() {
             return collisionDetected;
       }

public GameBoard(Context context, AttributeSet aSet) {
              super(context, aSet);
              p = new Paint();
              sprite1 = new Point(-1,-1);
              sprite2 = new Point(-1,-1);
              m = new Matrix();
              p = new Paint();
              bm1 = BitmapFactory.decodeResource(getResources(), R.drawable.asteroid);
              bm2 = BitmapFactory.decodeResource(getResources(), R.drawable.ufo);
              sprite1Bounds = new Rect(0,0, bm1.getWidth(), bm1.getHeight());
              sprite2Bounds = new Rect(0,0, bm2.getWidth(), bm2.getHeight());
       }

synchronized private void initializeStars(int maxX, int maxY) {
             starField = new ArrayList<Point>();
             for (int i=0; i<NUM_OF_STARS; i++) {
                    Random r = new Random();
                    int x = r.nextInt(maxX-5+1)+5;
                    int y = r.nextInt(maxY-5+1)+5;
                    starField.add(new Point (x,y));
             }
             collisionDetected = false;
       }

private boolean checkForCollision() {
             if (sprite1.x<0 && sprite2.x<0 && sprite1.y<0 && sprite2.y<0) return
false;
             Rect r1 = new Rect(sprite1.x, sprite1.y, sprite1.x +
sprite1Bounds.width(),  sprite1.y + sprite1Bounds.height());
             Rect r2 = new Rect(sprite2.x, sprite2.y, sprite2.x +
sprite2Bounds.width(), sprite2.y + sprite2Bounds.height());
             Rect r3 = new Rect(r1);
             if(r1.intersect(r2)) {
                    for (int i = r1.left; i<r1.right; i++) {
                           for (int j = r1.top; j<r1.bottom; j++) {
                                  if (bm1.getPixel(i-r3.left, j-r3.top)!=
Color.TRANSPARENT) {
                                        if (bm2.getPixel(i-r2.left, j-r2.top) !=
Color.TRANSPARENT) {
                                              lastCollision = new Point(sprite2.x +
i-r2.left, sprite2.y + j-r2.top);
                                              return true;
                                        }
                                  }
                            }
                     }
             }
             lastCollision = new Point(-1,-1);
             return false;
      }
      @Override
      synchronized public void onDraw(Canvas canvas) {
            p.setColor(Color.BLACK);
            p.setAlpha(255);
         p.setStrokeWidth(1);
            canvas.drawRect(0, 0, getWidth(), getHeight(), p);

if (starField==null) {
                   initializeStars(canvas.getWidth(), canvas.getHeight());
            }
            p.setColor(Color.CYAN);
            p.setAlpha(starAlpha+=starFade);
            if (starAlpha>=252 || starAlpha <=80) starFade=starFade*-1;
            p.setStrokeWidth(5);
            for (int i=0; i<NUM_OF_STARS; i++) {
                   canvas.drawPoint(starField.get(i).x, starField.get(i).y, p);
            }

if (sprite1.x>=0) {
                   m.reset();
                   m.postTranslate((float)(sprite1.x), (float)(sprite1.y));
                   m.postRotate(sprite1Rotation,
(float)(sprite1.x+sprite1Bounds.width()/2.0),
(float)(sprite1.y+sprite1Bounds.width()/2.0));
                   canvas.drawBitmap(bm1, m, null);
                   sprite1Rotation+=5;
                   if (sprite1Rotation >= 360) sprite1Rotation=0;
             }
             if (sprite2.x>=0) {
                    canvas.drawBitmap(bm2, sprite2.x, sprite2.y, null);
             }
             collisionDetected = checkForCollision();
             if (collisionDetected ) {
                     p.setColor(Color.RED);
                     p.setAlpha(255);
                 p.setStrokeWidth(5);
                 canvas.drawLine(lastCollision.x - 5, lastCollision.y - 5,
lastCollision.x + 5, lastCollision.y + 5, p);
                 canvas.drawLine(lastCollision.x + 5, lastCollision.y - 5,
lastCollision.x - 5, lastCollision.y + 5, p);
              }
       }
}

5. This is the first step in which we will be writing new code. In the /src/Main.java file we are going to add a new flag that indicates whether the player is trying to accelerate. We determine this based on whether the player's finger is lifted from the phone's display. In other words, the longer the player touches a finger to the screen, the faster the UFO accelerates. When the player lifts his or her finger, deceleration begins.

The new method updateVelocity() is responsible for adding or subtracting from the sprite's current velocity, respecting the upper and lower boundaries we have set. This method gets called each time our frame update callback occurs. Then we update our label with the new speed. The rest happens auto-magically.

Main.java

package com.authorwjf.gamedevtut05;
import java.util.Random;
import com.authorwjf.drawing.GameBoard;
import android.os.Bundle;
import android.os.Handler;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
import android.widget.TextView;
import android.app.Activity;
import android.graphics.Point;
public class Main extends Activity implements OnClickListener{
       private Handler frame = new Handler();
       private Point sprite1Velocity;
       private Point sprite2Velocity;
       private int sprite1MaxX;
       private int sprite1MaxY;
       private int sprite2MaxX;
       private int sprite2MaxY;
       //acceleration flag
       private boolean isAccelerating = false;
       private static final int FRAME_RATE = 20; //50 frames per second
       //Method for getting touch state--requires android 2.1 or greater
       @Override
       synchronized public boolean onTouchEvent(MotionEvent ev) {
       final int action = ev.getAction();
       switch (action & MotionEvent.ACTION_MASK) {
             case MotionEvent.ACTION_DOWN:
             case MotionEvent.ACTION_POINTER_DOWN:
                     isAccelerating = true;
              break;
         case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_POINTER_UP:
                    isAccelerating = false;
             break;
       }
           return true;
       }
       //Increase the velocity towards five or decrease
       //back to one depending on state
       private void updateVelocity() {
             int xDir = (sprite2Velocity.x > 0) ? 1 : -1;
             int yDir = (sprite2Velocity.y > 0) ? 1 : -1;
             int speed = 0;
             if (isAccelerating) {
                    speed = Math.abs(sprite2Velocity.x)+1;
             } else {
                    speed = Math.abs(sprite2Velocity.x)-1;
             }
             if (speed>5) speed =5;
             if (speed<1) speed =1;
             sprite2Velocity.x=speed*xDir;
             sprite2Velocity.y=speed*yDir;
      }
      @Override
    public void onCreate(Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      setContentView(R.layout.main);
      Handler h = new Handler();
      ((Button)findViewById(R.id.the_button)).setOnClickListener(this);
      h.postDelayed(new Runnable() {
                    @Override
                    public void run() {
                           initGfx();
                    }
      }, 1000);
   }
      private Point getRandomVelocity() {
            Random r = new Random();
            int min = 1;
            int max = 5;
            int x = r.nextInt(max-min+1)+min;
            int y = r.nextInt(max-min+1)+min;
            return new Point (x,y);
      }

private Point getRandomPoint() {
            Random r = new Random();
         int minX = 0;
         int maxX = findViewById(R.id.the_canvas).getWidth() -
((GameBoard)findViewById(R.id.the_canvas)).getSprite1Width();
            int x = 0;
         int minY = 0;
         int maxY = findViewById(R.id.the_canvas).getHeight() -
((GameBoard)findViewById(R.id.the_canvas)).getSprite1Height();
         int y = 0;
           x = r.nextInt(maxX-minX+1)+minX;
           y = r.nextInt(maxY-minY+1)+minY;
           return new Point (x,y);
      }

synchronized public void initGfx() {
      ((GameBoard)findViewById(R.id.the_canvas)).resetStarField();
      Point p1, p2;
      do {
             p1 = getRandomPoint();
             p2 = getRandomPoint();
      } while (Math.abs(p1.x - p2.x) <
((GameBoard)findViewById(R.id.the_canvas)).getSprite1Width());
      ((GameBoard)findViewById(R.id.the_canvas)).setSprite1(p1.x, p1.y);
      ((GameBoard)findViewById(R.id.the_canvas)).setSprite2(p2.x, p2.y);
      sprite1Velocity = getRandomVelocity();
      sprite2Velocity = new Point(1,1);
      sprite1MaxX = findViewById(R.id.the_canvas).getWidth() -
((GameBoard)findViewById(R.id.the_canvas)).getSprite1Width();
      sprite1MaxY = findViewById(R.id.the_canvas).getHeight() -
((GameBoard)findViewById(R.id.the_canvas)).getSprite1Height();
      sprite2MaxX = findViewById(R.id.the_canvas).getWidth() -
((GameBoard)findViewById(R.id.the_canvas)).getSprite2Width();
      sprite2MaxY = findViewById(R.id.the_canvas).getHeight() -
((GameBoard)findViewById(R.id.the_canvas)).getSprite2Height();
      ((Button)findViewById(R.id.the_button)).setEnabled(true);
      frame.removeCallbacks(frameUpdate);
      ((GameBoard)findViewById(R.id.the_canvas)).invalidate();
      frame.postDelayed(frameUpdate, FRAME_RATE);
   }
   @Override
   synchronized public void onClick(View v) {
         initGfx();
   }

private Runnable frameUpdate = new Runnable() {
        @Override
        synchronized public void run() {
              if (((GameBoard)findViewById(R.id.the_canvas)).wasCollisionDetected()) {
              Point collisionPoint =
((GameBoard)findViewById(R.id.the_canvas)).getLastCollision();
              if (collisionPoint.x>=0) {
                     ((TextView)findViewById(R.id.the_other_label)).setText("Last

Collision XY

("+Integer.toString(collisionPoint.x)+","+Integer.toString(collisionPoint.y)+")");
              }
              return;
       }
             frame.removeCallbacks(frameUpdate);
             //Add our call to increase or decrease velocity
             updateVelocity();
             //Display UFO speed
             ((TextView)findViewById(R.id.the_label)).setText("Sprite Acceleration
("+Integer.toString(sprite2Velocity.x)+","+Integer.toString(sprite2Velocity.y)+")");
             Point sprite1 = new Point
(((GameBoard)findViewById(R.id.the_canvas)).getSprite1X(),
                              ((GameBoard)findViewById(R.id.the_canvas)).getSprite1Y()) ;
             Point sprite2 = new Point
(((GameBoard)findViewById(R.id.the_canvas)).getSprite2X(),
                               ((GameBoard)findViewById(R.id.the_canvas)).getSprite2Y());
             sprite1.x = sprite1.x + sprite1Velocity.x;
             if (sprite1.x > sprite1MaxX || sprite1.x < 5) {
                    sprite1Velocity.x *= -1;
             }
             sprite1.y = sprite1.y + sprite1Velocity.y;
             if (sprite1.y > sprite1MaxY || sprite1.y < 5) {
                    sprite1Velocity.y *= -1;
             }
             sprite2.x = sprite2.x + sprite2Velocity.x;
             if (sprite2.x > sprite2MaxX || sprite2.x < 5) {
                    sprite2Velocity.x *= -1;
             }
             sprite2.y = sprite2.y + sprite2Velocity.y;
             if (sprite2.y > sprite2MaxY || sprite2.y < 5) {
                    sprite2Velocity.y *= -1;
             }
             ((GameBoard)findViewById(R.id.the_canvas)).setSprite1(sprite1.x,
sprite1.y);
          ((GameBoard)findViewById(R.id.the_canvas)).setSprite2(sprite2.x, sprite2.y);
             ((GameBoard)findViewById(R.id.the_canvas)).invalidate();
             frame.postDelayed(frameUpdate, FRAME_RATE);
        }
    };
}

Congratulations! You've just written your first game for an Android powered device. Don't believe me? Load the resulting code to your device or emulator and take it for a spin. Watch out for that asteroid! If it gets to close, simply hold your finger down on the phone to give your ship a boost.

At this point, you have all the elements of a real video game. I leave it as an exercise to the reader to add more sophisticated controls, sounds effects, score keeping -- the works!

For those readers who have kept up with this series from the beginning I extend a thank you and invite you to post comments and questions below.  This series was a blast for me to write. I appreciate the editors at TechRepublic taking a chance on it, and I hope readers find it informative and a lot of fun.

About

William J Francis began programming computers at age eleven. Specializing in embedded and mobile platforms, he has more than 20 years of professional software engineering under his belt, including a four year stint in the US Army's Military Intellige...

7 comments
nomorewine
nomorewine

Thank you for the great article. Could you please write an article on how to do animated sprites or how to make a complex sprites with separate parts being animated? Looking forward to read more articles on how to make games.

nomorewine
nomorewine

My first android game :) Thank you for sharing.

davidw32
davidw32

Thanks for the tutorial, a well-written and clearly explained article. But does it keep running even after being paused? I re-created your application and then did one of my own. This involved a sound effect on some of the 'asteroid' movements - and I found that, even after exiting the app, the sound effect continues to be played. I tried adding the sound to your app and the same happens. Does it mean the graphics are still running in the background? Please tell me if this is not the place for such a question. In Main.java I added public MediaPlayer mp; in the top and made the following change: if (sprite1.x > sprite1MaxX || sprite1.x < 5) { sprite1Velocity.x *= -1; //sound effect added here mp = MediaPlayer.create(Main.this, R.raw.splat1); mp.start(); mp.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { public void onCompletion(MediaPlayer mp) { mp.release(); }; }); } I have tried several ways to stop it, including @Override protected void onPause() { super.onPause(); if (mp != null) { mp.stop(); mp.release(); mp = null; } }

josh.lake
josh.lake

I worked through this series as part of an android development class that I'm taking. This was a very clear and simple example that also left room for someone to make improvements. I really enjoyed it. Thanks so much.

AlphaStriker
AlphaStriker

Thank you for this blog series! It was very informative and easy to follow, a great starting point for Android game development.

authorwjf
authorwjf

Try overriding the onDestroy, remove the callback: frame.removeCallbacks(frameUpdate); then stop and release the media player. Just FYI in my own games I use the Android soundpool class for sound fx. Media player is designed to continue to run in the background so your music is not interrupted.

davidw32
davidw32

Thanks authorwjf. Yes, I've found that too. Soundpool works much better for short bits of sound used frequently.