Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Bob's Quest (Conquering Android Wear, Part 2)

0.00/5 (No votes)
14 Oct 2015 1  
Introducing Bob's Quest, a Flappy Bird clone (of sorts), in which Bob must avoid crashing into pillars as he leaps through space.

Get it on Google Play

Introduction

Hello and welcome to my latest CP article!

In Part 1 of this series, I released Wearable Chess, the world's first free & open-source chess game for Android Wear. I also included a FAQ for beginners and a guide to setting up your Android Wear Dev Environment. Click here to get Wearable Chess on Google Play.

In this article (Part 2), I am going to show you from start-to-finish how I built Bob's Quest. (Originally I wanted to do this for Part 1 as well, but the complexity of Wearable Chess meant that a complete, start-to-finish walkthrough wasn't feasible. Instead, I focused more on the general principles behind the app's design.)

Anyway, that's enough introduction. Let's get coding! :bob:

If you have any comments or suggestions while reading my code, feel free to post them in the comments section at the bottom of the page. :D

The Game Loop

There is one common concept that underlies almost all computer games today - the game loop.

A simple game loop, in pseudo-code, looks like this:

while (true) {
    GetUserInput(); //Get input from keyboard, mouse, touch, etc
    DoGameLogic(); //Use the above input to do explosions, physics calculations, etc, for this frame
    RefreshGUI(); //Draw the next frame
}

In Bob's Quest, our game loop takes the following form:

package com.orangutandevelopment.bobsquest;

import ...

public class BobView extends View {
    final int refresh_interval = 40;
    private double delta_time = 0;
    private Date last_updated;
    private Handler h;

    public BobView(Context context) {
        super(context);
        init();
    }

    public void init() {
        setWillNotDraw(false); //Ensures that drawing occurs immediately when requested.

        //User input
        this.setOnTouchListener(new OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                //Handle touch event
				...
                return false;
            }
        });

        //Game loop
        last_updated = new Date();
        h = new Handler();
        h.postDelayed(new Runnable() {
            @Override
            public void run() {
                Update();
                h.postDelayed(this, refresh_interval);
            }
        }, refresh_interval);
    }

    public void Update() {
        //How long since last update?
        delta_time = (new Date()).getTime() - last_updated.getTime();
        
		...
		//Make changes to game data
		...

        //Update GUI!
        this.postInvalidate();
        last_updated = new Date();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        ...
		//Render the GUI
		...
    }
}

Creating Game Objects

Bob's Quest uses the following class to create the parallax background, the randomly generated walls that Bob needs to avoid, and even Bob himself!

public class GameObject {
    public double X = 0;
    public double Y = 0;
    public double Horizontal_Speed = 0;
    public double Vertical_Speed = 0;
    public double scaling = 1;

    public GameObject() {

    }
}

In BobView.java, the GameObjects are declared like this:

GameObject Bob = new GameObject();
ArrayList<GameObject> walls = new ArrayList<>();
ArrayList<GameObject> stars = new ArrayList<>();
ArrayList<GameObject> clouds = new ArrayList<>();

To draw a game object, we declare a few Bitmaps...

public Bitmap Bob_Image;
public Bitmap Background_Image;
public Bitmap Star_Image;
public Bitmap Cloud_Image;

private Paint mPaint; //Used for drawing Bitmaps

public void init() {
    ...

    //Decode resources
    Bob_Image = BitmapFactory.decodeResource(getResources(), R.drawable.bob_w);
    Background_Image = BitmapFactory.decodeResource(getResources(), R.drawable.bg);
    Cloud_Image = BitmapFactory.decodeResource(getResources(), R.drawable.cloud_t);
    Star_Image = BitmapFactory.decodeResource(getResources(), R.drawable.star_t);

    mPaint = new Paint();
    mPaint.setStyle(Paint.Style.FILL);
    mPaint.setColor(Color.BLACK);
    mPaint.setTypeface(tf);
	
	...
}

...and then we use these voids to draw our object:

private void drawGameObject(Canvas canvas, Paint paint, GameObject object, Bitmap bitmap) {
    canvas.drawBitmap(bitmap, new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()), getGameObjectRect(object, bitmap), paint);
}

private RectF getGameObjectRect(GameObject object, Bitmap bitmap) {
    return new RectF((float) object.X, (float) object.Y, (float) object.X + (float)(object.scaling * bitmap.getWidth()), (float) object.Y + (float)(object.scaling * bitmap.getHeight()));
}

One thing that may catch C# developers like me by surprise is that Java seems to define Rectangles by (x1, y1, x2. y2), rather than (x, y, width, height).

Creating the Parallax Background

(Animated .GIF above, may take a while to load)

It took a few hours to implement this animation correctly - in other words, to make it enhance the visual experience without distracting the user. The hardest part was simply creating the images themselves and experimenting to get the colors right. Unfortunately - because images and colors will be different for every game - I can't give you much help with this, other than advising you to experiment, pay attention to details, and have patience.

Once the images were done, though, creating parallax was fairly straightforward. This is how the background, clouds, and stars were drawn:

@Override
protected void onDraw(Canvas canvas) {
    mPaint.setColor(Color.argb(255, 0, 0, 0));

    //Background first
    canvas.drawBitmap(Background_Image, new Rect(0, 0, Background_Image.getWidth(), Background_Image.getHeight()), new Rect(0, 0, canvas.getWidth(), canvas.getHeight()), mPaint);

    //Clouds and stars next
    for (int j = 0; j < stars.size(); ++j) {
        drawGameObject(canvas, mPaint, stars.get(j), Star_Image);
    }
    for (int j = 0; j < clouds.size(); ++j) {
        drawGameObject(canvas, mPaint, clouds.get(j), Cloud_Image);
    }
    
    ...
}

To move the clouds and stars, I did this:

for (int j = 0; j < stars.size(); ++j) {
    stars.get(j).X -= 1;
    if (stars.get(j).X < -30)
        stars.remove(j);
}
for (int j = 0; j < clouds.size(); ++j) {
    //Clouds move twice as fast as stars.
    clouds.get(j).X -= 2;

    //Removes them once they pass off the screen
    if (clouds.get(j).X < -1 * clouds.get(j).scaling * Cloud_Image.getWidth())
        clouds.remove(j);
}

New stars and clouds are generated at random like this:

if (times == 50) {
    times = 0;
    GameObject c = new GameObject();
    c.Y = r.nextInt(180) + 120;
    c.X = 340;
    c.scaling = r.nextDouble();
    clouds.add(c);
}
if (times == 10 || times == 20 || times == 30 || times == 40 || times == 50 || times == 0) {
    GameObject s = new GameObject();
    s.Y = r.nextInt(320);
    s.X = 340;
    s.scaling = r.nextDouble() * .25;
    stars.add(s);
}

Drawing the Walls

These required some fancy canvas work, Here's how I drew them, explained heavily in comments:

for (int j = 0; j < walls.size(); ++j) {
    GameObject w = walls.get(j);

    //Draw the black center top part
    canvas.drawRect((float) w.X + 3, 0, (float) w.X + 14, (float) w.Y - 60, mPaint);
 
    //Change color for the white outline
    mPaint.setColor(Color.argb(255, 230, 250, 252));
    
    //Top left white line
    canvas.drawRect((float) w.X, 0, (float) w.X + 2, (float) w.Y - 60, mPaint);
    
    //Top right white line
    canvas.drawRect((float)w.X + 15, 0, (float)w.X + 17, (float)w.Y - 60, mPaint);
    
    //Top white block
    canvas.drawRect((float) w.X - 4, (float) w.Y - 60, (float) w.X + 21, (float) w.Y - 54, mPaint);
    
    //Change color to draw bottom half
    mPaint.setColor(Color.argb(255, 0, 0, 0));

    //Bottom black region
    canvas.drawRect((float) w.X, (float) w.Y + 60, (float) w.X + 17, 320, mPaint);
    
    //Outline color
    mPaint.setColor(Color.argb(255, 230, 250, 252));
    
    //Bottom left white line
    canvas.drawRect((float) w.X, (float) w.Y + 60, (float) w.X + 2, 320, mPaint);
    
    //Bottom right white line
    canvas.drawRect((float) w.X + 15, (float)w.Y + 60, (float) w.X + 17, 320, mPaint);
    
    //Bottom white block
    canvas.drawRect((float) w.X - 4, (float) w.Y + 60, (float) w.X + 21, (float) w.Y + 66, mPaint);
    
    //Change color for repeat
    mPaint.setColor(Color.argb(255, 0, 0, 0));
}

The walls were animated just like the clouds and stars were, except that they move twice as fast as the clouds and four times as fast as the stars. Scoring is also implemented by checking for when we pass Bob.

for (int j = 0; j < walls.size(); ++j) {
    walls.get(j).X -= 4;
    if (walls.get(j).X < -30)
        walls.remove(j);
        
    //Did we score?
    if (Math.abs(walls.get(j).X - Bob.X) < 2)
        ++score;
	
	...
}

Animating Bob

To make Bob "bob" up and down like a real object in gravity, we need to define a low gravitational constant and change Bob's rate of descent in accordance with this constant.

We also need to prevent him from falling down before the game starts, instead making him gently float on the screen, waiting for the user to start with a tap.

if (game_started) {
    Bob.Y -= Bob.Vertical_Speed * delta_time;
    Bob.Vertical_Speed -= .00075 * delta_time; //.00075 is the gravitational constant here

    //Don't let him go through the roof or fall through the floor!
    if (Bob.Y > 320 - Bob_Space.height())
        Bob.Y = 320 - Bob_Space.height();
    if (Bob.Y < 0)
        EndGame();
} else {
    Bob.Y += bobbing ? .5 : -.5;
}

The bobbing boolean is changed every few cycles using the same times integer used to control when new clouds and stars are created.

To make Bob jump up when the screen is tapped, we need to implement an OnTouchListener in the init() function:

this.setOnTouchListener(new OnTouchListener() {
    @Override
    public boolean onTouch(View v, MotionEvent event) {
        if (!game_over) {
            game_started = true;
            Bob.Vertical_Speed = .3;
        }
        return false;
    }
});

Setting Bob's Vertical Speed to .3 means that he is going up at a rate of .3 pixels per frame. Over the next few frames, the gravitational constant will pull Bob's speed below 0 and make him start falling down again.

Drawing Bob is very easy, we just use the same drawGameObject() void from earlier:

drawGameObject(canvas, mPaint, Bob, Bob_Image);

Detecting Collisions

Detecting collisions is quite simple. All we need to do is create a void that find the Rectangles occupied by a given wall, like this:

private RectF[] getWallSpaceRect(GameObject wall) {
    RectF top = new RectF((float) wall.X, 0, (float) wall.X + 17, (float) wall.Y - 54);
    RectF bottom = new RectF((float) wall.X, (float)wall.Y + 60, (float) wall.X + 17, 320);
    return new RectF[] {top, bottom};
}

Then, we add this code to the end of the for loop that moves the walls - Java conveniently has a pre-built intersects() method!

RectF Bob_Space = getGameObjectRect(Bob, Bob_Image);

for (int j = 0; j < walls.size(); ++j) {
    ...
    
    RectF[] wall_space = getWallSpaceRect(walls.get(j));
    for (RectF r : wall_space) {
        if (RectF.intersects(r, Bob_Space)) {
            EndGame(); //We have a hit!
            break;
        }
    }
}

Using a Custom Font

To add a custom font to an Android Wear project, create a new folder on the same level as "java" & "res" called "assets".

Next, place your font files in that folder, as seen above. For this game, I used a cool font called Munro. To enable drawing on a canvas using this font, I used the below code in BobView.java:

public void init() {
    ...
    Typeface tf = Typeface.createFromAsset(getContext().getAssets(), "Munro.ttf");
    mPaint.setTypeface(tf);
    ...
}

@Override
protected void onDraw(Canvas canvas) {
    //Just an example
    canvas.drawText("Cool Font", 10, 10, mPaint);
}

Additionally, because I needed to use this font in XML-based GUI, I created a new class called CoolFontTextView that extended the default TextView class:

public class CoolFontTextView extends TextView {
    public CoolFontTextView(Context context, AttributeSet attrs) {
        super(context, attrs);
        this.setTypeface(Typeface.createFromAsset(context.getAssets(), "Munro.ttf"));
    }
}

Creating the GUI in XML

Let's start with the root elements of the XML document:

<?xml version="1.0" encoding="utf-8"?>
<android.support.wearable.view.WearableFrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
    android:layout_height="match_parent" android:id="@+id/container" tools:context=".MainActivity"
    tools:deviceIds="wear">

    <com.orangutandevelopment.bobsquest.BobView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/bob_view" />

    ...

</android.support.wearable.view.WearableFrameLayout>

The BobView is the most important GUI element. On top of the BobView element is a FrameLayout, which provides the dark, semi-transparent overlay of the "Game Over" sign:

<FrameLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:id="@+id/overlay_view"
    android:background="#aa000000"
    android:visibility="gone">

    ...

</FrameLayout>

Withing that FrameLayout is a vertically-aligned LinearLayout, within which are two CoolFontTextView's and an ImageButton:

<com.orangutandevelopment.bobsquest.CoolFontTextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:id="@+id/view"
    android:layout_gravity="center_horizontal"
    android:text="Game Over"
    android:textColor="#ffffff"
    android:textSize="32sp" />

<com.orangutandevelopment.bobsquest.CoolFontTextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:id="@+id/tx_score"
    android:layout_gravity="center_horizontal"
    android:text="Score: 2 | Top: 13"
    android:textColor="#e6fafc"
    android:textSize="22sp"
    android:layout_marginTop="3dp"
    android:layout_marginBottom="7dp" />

<ImageButton
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    android:id="@+id/btn_new"
    android:layout_gravity="center_horizontal"
    android:src="@drawable/ic_undo_white_24dp"
    android:layout_marginLeft="15dp"
    android:layout_marginRight="15dp"
    android:background="#0B81FF"
    android:padding="7dp" />

Adding Event Handlers

We're almost done! Now we need to implement a way to react when Bob crashes into a wall. I did this by creating the following interface:

public interface OnGameFinishedListener {
    void onEvent(int score);
}

Then, in BobView.java, I wrote the following, which allows multiple listeners of the same type to be attached to the same event. Bob's Quest only needs one, but supporting many is good practice, because you may need to add more in the future.

ArrayList<OnGameFinishedListener> mListeners = new ArrayList<>();
public void addListener(OnGameFinishedListener listener) {
    mListeners.add(listener);
}

Whenever I needed to fire this event from within BobView,java, I used the following code:

for (OnGameFinishedListener hl : mListeners)
    hl.onEvent(score);

The Main Activity jumps on the receiving side and calls the addListener() method in MainActivity.java. This code shows the "Game Over" sign when Bob crashes.

mBobView.addListener(new OnGameFinishedListener() {
    @Override
    public void onEvent(int score) {
        if (score > TopScore)
            TopScore = score;

        mTxScore.setText("Score: " + score + " | Top: " + TopScore);
        mGameOver.setVisibility(View.VISIBLE);
    }
});

On a similar note, we also handle the New Game button in MainActivity.java like this:

mNewGame.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        mGameOver.setVisibility(View.GONE);
        mBobView.NewGame();
    }
});

Remembering the Top Score

Remembering the Top Score is easy. All we need is the following implementation in MainActivity.java:

private int TopScore = 0;
public static final String PREFS_NAME = "BobsQuestPrefs";

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    TopScore = this.getSharedPreferences(PREFS_NAME, 0).getInt("Top_Score", 0);
    ...
}

@Override
protected void onStop() {
    super.onStop();
    SharedPreferences settings = this.getSharedPreferences(PREFS_NAME, 0);
    SharedPreferences.Editor editor = settings.edit();
    editor.putInt("Top_Score", TopScore);
    editor.commit();
}

That's it! :cool:

Implementing Hold-to-Exit

I covered this in my last article (Part 1) - but for the sake of completeness, I'll cover this again here.

The first step is creating a custom resource called hold_to_exit.xml:

<resources>
    <style name="HoldToExit" parent="@android:style/Theme.DeviceDefault.Light">
        <item name="android:windowSwipeToDismiss">false</item>
    </style>
</resources>

Next, in AndroidManifest.xml, change the style of the relevant Activity to HoldToExit:

...
android:name=".MainActivity"
android:label="@string/app_name"
android:theme="@style/HoldToExit" >

Then, add this element to your XML layout file (preferably as the last child of the root element):

<android.support.wearable.view.DismissOverlayView
    android:id="@+id/dismiss_overlay"
    android:layout_height="match_parent"
    android:layout_width="match_parent"/>

Lastly, in MainActivity.java, implement the following:

private DismissOverlayView mDismissOverlay;
private GestureDetector mDetector;
...

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    mDismissOverlay = (DismissOverlayView) findViewById(R.id.dismiss_overlay);
    mDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() {
        public void onLongPress(MotionEvent ev) {
            mDismissOverlay.show();
        }
    });
    ...
}
    
@Override
public boolean dispatchTouchEvent (MotionEvent e) {
    return mDetector.onTouchEvent(e) || super.dispatchTouchEvent(e);
}

Supporting Round Screens

When developing for Android Wear, care must be taken to support round screens.

Round screens were supported in Bob's Quest by:

  • Placing the points-scored counter in the center-top of the screen, where it can't be cropped off.
  • Offsetting Bob towards the center of the screen by default, so that his movement is not cropped.
  • Restricting the positions of the holes in the walls to a range within that of a circular screen's field of view.
  • Keeping buttons above the 290px vertical mark (for the Moto 360).

Wrapping Up

Thanks for reading to the end! :D

I really enjoyed writing this article, and I hope that it inspires other developers to see what they can do with Android Wear. There still is a lot of untapped potential in wearable technology.

As always - If you have any comments, questions, or suggestions of any kind, please post them below. If you liked this article, don't forget to give it a 5! :cool:

If you have an Android Wear Smartwatch, but you'd prefer not to download the source & compile Bob's Quest for yourself, then you can get the pre-compiled app for a small fee on Google Play:

Get it on Google Play

History

  • 14/10/15 First Version Published
  • 15/10/15 Added the links to Google Play and reworded/rearranged a few sections

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here