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();
DoGameLogic();
RefreshGUI();
}
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);
this.setOnTouchListener(new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
...
return false;
}
});
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() {
delta_time = (new Date()).getTime() - last_updated.getTime();
...
...
this.postInvalidate();
last_updated = new Date();
}
@Override
protected void onDraw(Canvas canvas) {
...
...
}
}
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;
public void init() {
...
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));
canvas.drawBitmap(Background_Image, new Rect(0, 0, Background_Image.getWidth(), Background_Image.getHeight()), new Rect(0, 0, canvas.getWidth(), canvas.getHeight()), mPaint);
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.get(j).X -= 2;
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);
canvas.drawRect((float) w.X + 3, 0, (float) w.X + 14, (float) w.Y - 60, mPaint);
mPaint.setColor(Color.argb(255, 230, 250, 252));
canvas.drawRect((float) w.X, 0, (float) w.X + 2, (float) w.Y - 60, mPaint);
canvas.drawRect((float)w.X + 15, 0, (float)w.X + 17, (float)w.Y - 60, mPaint);
canvas.drawRect((float) w.X - 4, (float) w.Y - 60, (float) w.X + 21, (float) w.Y - 54, mPaint);
mPaint.setColor(Color.argb(255, 0, 0, 0));
canvas.drawRect((float) w.X, (float) w.Y + 60, (float) w.X + 17, 320, mPaint);
mPaint.setColor(Color.argb(255, 230, 250, 252));
canvas.drawRect((float) w.X, (float) w.Y + 60, (float) w.X + 2, 320, mPaint);
canvas.drawRect((float) w.X + 15, (float)w.Y + 60, (float) w.X + 17, 320, mPaint);
canvas.drawRect((float) w.X - 4, (float) w.Y + 60, (float) w.X + 21, (float) w.Y + 66, mPaint);
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);
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;
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();
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) {
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:
="1.0"="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:
History
- 14/10/15 First Version Published
- 15/10/15 Added the links to Google Play and reworded/rearranged a few sections