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 50×50 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("LastCollision 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.