© Ted Hagos, Mario Zechner, J.F. DiMarzio and Robert Green 2020
T. Hagos et al.Beginning Android Games Developmenthttps://doi.org/10.1007/978-1-4842-6121-7_7

7. Building the Balloon Popper Game

Ted Hagos1 , Mario Zechner2, J. F. DiMarzio3 and Robert Green4
(1)
Makati, Philippines
(2)
Graz, Steiermark, Austria
(3)
Kissimmee, FL, USA
(4)
Portland, OR, USA
 
Let’s jump into the next game. This game will be simpler than the previous one we built, both in mechanics and technique, but this one will incorporate the use of audio and some sound effects. In this chapter, we’ll discuss the following:
  • How to use ImageView as a graphic object in the game

  • Use the ValueAnimator in animating movements of game objects

  • Use AudioManager, MediaPlayer, and the SoundPool classes to add audio effects and music to your game

  • Use Java threads to run things in the background

Like in the previous chapter, I’ll show the code snippets necessary to build the game; at times, even full code listings of some classes will be provided. The best way to understand and learn the programming techniques in this chapter is to download the source code for the game and keep it open in Android Studio as you read through the chapter sections. If you want to follow along and build the project yourself, it’s best to keep the source code for the chapter handy, so you can copy and paste particular snippets as necessary.

Game Mechanics

We will make balloons float from the bottom of the screen, rising to the top. The players’ goal is to pop as many balloons as they can before the balloons reach the top of the screen. If a balloon reaches the top without being popped, that will be a point against the user. The player will have five lives (pins, in this case); each time the player misses a balloon, they lose a pin. When the pins run out, it’s game over.

We’ll introduce the concept of levels. In each level, there will be several balloons. As the player progresses in levels, the time it takes for the balloon to float from the bottom to the top becomes less and less; the balloons float faster as the level increases. It’s that simple. Figure 7-1 shows a screenshot of Balloon Popper game.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig1_HTML.jpg
Figure 7-1

Pop balloons

The balloons will show up on random locations from the bottom of the screen.

We will devote the lower strip of the screen to game statistics. We will use this to display the score and the level. On the lower left side, we’ll place a Button view which the user can use to start the game and to start a new level.

The game will be played in full screen (like our previous game), and it will be done so exclusively in landscape mode.

Creating the Project

Create a new project with an empty Activity, as shown in Figure 7-2.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig2_HTML.jpg
Figure 7-2

New project with an empty Activity

In the window that follows, fill out the project details, as shown in Figure 7-3.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig3_HTML.jpg
Figure 7-3

Create a new project

Click Finish to create the project.

Drawing the Background

The game has a background image; you can do without one, but it adds to the user experience. Surely, if you’ll release a commercial game, you’ll use an image that has more professional polish. I grabbed this image from one of the public domain sites; feel free to use any image you prefer.

When I got the background image, I downloaded only one file and named it “background.jpg.” I could have used this image and dropped it in the app/res/drawable folder and be done with it. Had I done that, the runtime will use this same image file as background for different display densities, and it will try to make that adjustment while the game is playing, which may result in a jittery game experience. So, it’s very important to provide a background image for different screen densities. If you’re quite handy with Photoshop or GIMP, you can try to generate the images for different screens; or, you can use just one background image and then use an application called Android Resizer (https://github.com/asystat/Final-Android-Resizer) to generate the images for you. You can download the application from its GitHub repo and use it right away. It’s an executable Java archive (JAR) file.

Once downloaded, you can open the zipped file and double-click the file Final Android Resizer.jar in the Executable Jar folder (shown in Figure 7-4).
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig4_HTML.jpg
Figure 7-4

Android Resizer app

In the window that follows (Figure 7-5), modify the settings of the “export” section; the various screen density targets are in the Export section. I ticked off ldpi because we don’t have to support the low-density screens. I also ticked off the tvdpi because our targets don’t include Android TVs.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig5_HTML.jpg
Figure 7-5

Android Resizer

Click the browse button of the Android Resizer to set the target folder where you would like to generate the images, as shown in Figure 7-6; then click Choose.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig6_HTML.jpg
Figure 7-6

Target folder for generated images

../images/340874_4_En_7_Chapter/340874_4_En_7_Fig7_HTML.jpg
Figure 7-7

Android Resizer, target directory set

The target directory (resources directory) should now be set. Remember this directory because you will fetch the images from here and transfer them to the Android project. In the window that follows (Figure 7-7),you will set the target directory.

Next, drag the image you’d like to resize in the center area of the Resizer app. As soon as you drop the image, the conversion begins. When the conversion finishes, you’ll see a message “Done! Gimme some more…”, as shown in Figure 7-8.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig8_HTML.jpg
Figure 7-8

Android Resizer, done with the conversion

The generated images are neatly placed in their corresponding folders, as shown in Figure 7-9.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig9_HTML.jpg
Figure 7-9

Generated images

The background image file isn’t the only thing we need to resize. We also need to do this for the balloon image. We will use a graphic image to represent the balloons in the game. The balloon file is just a grayscale image (shown in Figure 7-10); we’ll add the colors in the program later.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig10_HTML.jpg
Figure 7-10

Grayscale image of the balloon

Drag and drop the balloon image in the Resizer app, as you did with the background file. When it’s done, the Android Resizer would have generated the files balloons.png and background.jpg in the appropriate folders (as shown in Figure 7-11).
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig11_HTML.jpg
Figure 7-11

Generated files

We can now use these images for the project. To move the images to the project, open the app/res folder; you can do this by using a context action; right-click app/res, then choose Reveal in Finder (if you’re on macOS); if you’re on Windows, it will be Show in Explorer (as shown in Figure 7-12).
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig12_HTML.jpg
Figure 7-12

Reveal in Finder

Now, you can simply drag and drop the generated image folders (and files) into the correct folders in app/res/ directory.

Figure 7-13 shows an updated app/res directory of the project. I switched the scope of the Project tool from Android scope to Project scope to see the physical layout of the files. I usually change scopes, depending on what I need.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig13_HTML.jpg
Figure 7-13

app/res folder with the appropriate image files

Before we draw the background image, let’s take care of the screen orientation. It’s best to play this game in landscape mode; that’s why we’ll fix the orientation to landscape. We can do this in the AndroidManifest file. Edit the project’s AndroidManifest to match Listing 7-1; Figure 7-14 shows the location of the AndroidManifest file in the Project tool window.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig14_HTML.jpg
Figure 7-14

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  package="net.workingdev.popballoons">
  <application
    android:allowBackup="true"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:roundIcon="@mipmap/ic_launcher_round"
    android:supportsRtl="true"
    android:theme="@style/AppTheme">
    <activity android:name=".MainActivity"
      android:configChanges="orientation|keyboardHidden|screenSize"
      android:label="@string/app_name"
      android:screenOrientation="landscape"
      android:theme="@style/FullscreenTheme"
      >
      <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
  </application>
</manifest>
Listing 7-1

AndroidManifest.xml

The entries responsible for fixing the orientation to landscape are found on the attributes of the <activity> node in the manifest file. At this point, the project would have an error because the Android:theme=” style/FullScreenTheme” attribute doesn’t exist as of yet. We’ll fix that shortly.

Edit the /app/res/styles.xml file and add another style, as shown in Listing 7-2.
<resources>
  <!-- Base application theme. -->
  <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
    <!-- Customize your theme here. -->
    <item name="colorPrimary">@color/colorPrimary</item>
    <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
    <item name="colorAccent">@color/colorAccent</item>
  </style>
  <style name="FullscreenTheme" parent="AppTheme">
    <item name="android:windowBackground">@android:color/white</item>
  </style>
</resources>
Listing 7-2

/app/res/styles.xml

That should fix it. Figure 7-15 shows the app in its current state.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig15_HTML.jpg
Figure 7-15

PopBalloons

To load the background image from the app/res/mipmap folders, we will use the following code:
getWindow().setBackgroundDrawableResource(R.mipmap.background);
We need to call this statement in the onCreate() method of MainActivity, just before we call setContentView(). Listing 7-3 shows our (still) minimal MainActivity.
public class MainActivity extends AppCompatActivity {
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    getWindow().setBackgroundDrawableResource(R.mipmap.background);
    setContentView(R.layout.activity_main);
  }
}
Listing 7-3

MainActivity

Now, build and run the app. You will notice that the app has a background image now (as shown in Figure 7-16).
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig16_HTML.jpg
Figure 7-16

With background image

Game Controls and Pin Icons

We will use the bottom part of the screen to show the score and the level. We’ll also use this portion of the screen to place a button that triggers the start of the game and the start of the level.

Let’s fix the activity_main layout file first. Currently, this layout file is set to ConstraintLayout (this is the default), but we don’t need this layout, so we’ll replace it with the RelativeLayout. We’ll set the layout_width and layout_height of this container to match_parent so that it expands to the available space. Listing 7-4 shows our refactored main layout.
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  tools:context=".MainActivity">
</RelativeLayout>
Listing 7-4

activity_main

Next, we will add the Button and the TextView objects, which we’ll use to start the game and to display game statistics. The idea is to nest the TextViews inside a LinearLayout container, which is oriented horizontally, and then put it side by side with a Button control; then, we’ll enclose the Button and the LinearLayout container within another RelativeLayout container. Listing 7-5 shows the complete activity_main layout, with the game controls added.
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  tools:context=".MainActivity">
<!-- Buttons and status displays -->
<RelativeLayout
  android:layout_width="match_parent"
  android:layout_height="wrap_content"
  android:layout_alignParentBottom="true"
  android:background="@color/lightGrey">
  < Button
    android:id="@+id/go_button"
    style="?android:borderlessButtonStyle"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_alignParentStart="true"
    android:layout_centerVertical="true"
    android:text="@string/play_game"
    android:layout_alignParentLeft="true"/>
  <LinearLayout
    android:id="@+id/status_display"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_alignParentEnd="true"
    android:layout_centerVertical="true"
    android:layout_marginEnd="8dp"
    android:orientation="horizontal"
    tools:ignore="RelativeOverlap">
    <TextView
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:text="@string/level_label"
      android:textSize="20sp"
      android:textStyle="bold"
      tools:ignore="RelativeOverlap" />
    <TextView
      android:id="@+id/level_display"
      android:layout_width="40dp"
      android:layout_height="wrap_content"
      android:layout_marginEnd="32dp"
      android:gravity="end"
      android:text="@string/maxNumber"
      android:textSize="20sp"
      android:textStyle="bold" />
    <TextView
      android:id="@+id/score_label"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:text="@string/score_label"
      android:textSize="20sp"
      android:textStyle="bold"
      tools:ignore="RelativeOverlap" />
    <TextView
      android:id="@+id/score_display"
      android:layout_width="40dp"
      android:layout_height="wrap_content"
      android:layout_marginEnd="16dp"
      android:gravity="end"
      android:text="@string/maxNumber"
      android:textSize="20sp"
      android:textStyle="bold" />
  </LinearLayout>
</RelativeLayout>
</RelativeLayout>
Listing 7-5

activity_main.xml

We referenced a couple of string and color resources in activity_main.xml, and we need to add them to strings.xml and colors.xml in the resources folder.

Open colors.xml and edit it to match Listing 7-6.
<?xml version="1.0" encoding="utf-8"?>
<resources>
  <color name="colorPrimary">#008577</color>
  <color name="colorPrimaryDark">#00574B</color>
  <color name="colorAccent">#D81B60</color>
  <color name="lightGrey">#DDDDDD</color>
  <color name="pinColor">@color/black_overlay</color>
  <color name="black_overlay">#66000000</color>
</resources>
Listing 7-6

app/res/values/colors.xml

Open strings.xml and edit it to match Listing 7-7.
<resources>
  <string name="app_name">PopBalloons</string>
  <string name="play_game">Play</string>
  <string name="stop_game">Stop</string>
  <string name="score_label">Score:</string>
  <string name="maxNumber">999</string>
  <string name="level_label">Level:</string>
  <string name="wow_that_was_awesome">Wow, that was awesome</string>
  <string name="more_levels_than_ever">More Levels than Ever!</string>
  <string name="new_top_score">New Top Score!</string>
  <string name="your_top_score_is">Top score: %s</string>
  <string name="you_completed_n_levels">Levels completed: %s</string>
  <string name="game_over">Game over!</string>
  <string name="missed_that_one">Missed that one!</string>
  <string name="you_finished_level_n">You finished level %s!</string>
  <string name="popping_pin">Popping Pin</string>
</resources>
Listing 7-7

app/res/values/strings.xml

String literals are stored in strings.xml to avoid hardcoding the String literals in our program. This approach of using a resources file for String literals makes it easier to change the Strings later on—say, when you release the game to non-English speaking countries.

Figure 7-17 shows the app with game controls.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig17_HTML.jpg
Figure 7-17

With game controls

Next, let’s draw the pins. You can get the pins from Google’s material icons. These are SVG icons, so we don’t have to create multiple copies for different screen resolutions; they scale just fine. The vector definitions of the pins will be in the drawable folder. We’ll create two vector definitions for the pin; one image represents a whole pin (an unused game life) and the other a broken pin (a used game life).

We need to create these files inside the drawable folder; we can do this with the context menu actions. Right-click the app/res/drawable folder of the project, as shown in Figure 7-18.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig18_HTML.jpg
Figure 7-18

New drawable resource file

In the window that follows, type the name of the file (as shown in Figure 7-19).
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig19_HTML.jpg
Figure 7-19

New Resource File

Check to see that the Directory name is “drawable,” then click OK. Simply type pin for the file name; no need to put the XML extension, that will be automatically added by Android Studio. Do the same thing to create the file for pin_broken.

Edit the newly created resource files. Listings 7-8 and 7-9 show the code for pin.xml and pin_broken.xml, respectively.
<vector xmlns:android="http://schemas.android.com/apk/res/android"
  android:height="24dp"
  android:width="24dp"
  android:viewportWidth="24"
  android:viewportHeight="24">
  <path android:fillColor="#000" android:pathData="M16,12V4H17V2H7V4H8V12L6,14V16H11.2V22H12.8V16H18V14L16,12Z" />
</vector>
Listing 7-8

app/res/drawable/pin.xml

<vector xmlns:android="http://schemas.android.com/apk/res/android"
  android:height="24dp"
  android:width="24dp"
  android:viewportWidth="24"
  android:viewportHeight="24">
  <path android:fillColor="#000" android:pathData="M2,5.27L3.28,4L20,20.72L18.73,22L12.8,16.07V22H11.2V16H6V14L8,12V11.27L2,5.27M16,12L18,14V16H17.82L8,6.18V4H7V2H17V4H16V12Z" />
</vector>
Listing 7-9

app/res/drawable.pin_broken.xml

Figure 7-20 shows a preview of the pin in Android Studio.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig20_HTML.jpg
Figure 7-20

Preview of the pin image

Now that we have images for the pins, we can add them to the activity_main layout file. We’ll place five ImageView objects at the top part of the screen, and then we will point each ImageView to the pin images we recently created. Listing 7-10 shows a snippet of the pin definitions in XML.
<ImageView
  android:id="@+id/pushpin1"
  android:layout_width="40dp"
  android:layout_height="40dp"
  android:contentDescription="@string/popping_pin"
  android:src="@drawable/pin"
  android:tint="@color/pinColor" />
Listing 7-10

Pin definitions in XML

The android:src attribute points the ImageView to our vector drawing in the drawable folder.

Listing 7-11 shows the full activity_main.xml, which contains the game controls, the pin drawings, and the FrameLayout container, which will contain all our game action.
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  tools:context=".MainActivity">
  <FrameLayout
    android:id="@+id/content_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
  <!-- Container for pin icons -->
  <LinearLayout
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_alignParentEnd="true"
    android:layout_alignParentTop="true"
    android:layout_marginEnd="16dp"
    android:layout_marginTop="16dp"
    android:orientation="horizontal">
    <ImageView
      android:id="@+id/pushpin1"
      android:layout_width="40dp"
      android:layout_height="40dp"
      android:contentDescription="@string/popping_pin"
      android:src="@drawable/pin"
      android:tint="@color/pinColor" />
    <ImageView
      android:id="@+id/pushpin2"
      android:layout_width="40dp"
      android:layout_height="40dp"
      android:contentDescription="@string/popping_pin"
      android:src="@drawable/pin"
      android:tint="@color/pinColor" />
    <ImageView
      android:id="@+id/pushpin3"
      android:layout_width="40dp"
      android:layout_height="40dp"
      android:contentDescription="@string/popping_pin"
      android:src="@drawable/pin"
      android:tint="@color/pinColor" />
    <ImageView
      android:id="@+id/pushpin4"
      android:layout_width="40dp"
      android:layout_height="40dp"
      android:contentDescription="@string/popping_pin"
      android:src="@drawable/pin"
      android:tint="@color/pinColor" />
    <ImageView
      android:id="@+id/pushpin5"
      android:layout_width="40dp"
      android:layout_height="40dp"
      android:contentDescription="@string/popping_pin"
      android:src="@drawable/pin"
      android:tint="@color/pinColor" />
  </LinearLayout>
  <!-- Buttons and game statistics -->
  <RelativeLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_alignParentBottom="true"
    android:background="@color/lightGrey">
    < Button
      android:id="n"
      style="?android:borderlessButtonStyle"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:layout_alignParentStart="true"
      android:layout_centerVertical="true"
      android:text="@string/play_game" />
    <LinearLayout
      android:id="@+id/status_display"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:layout_alignParentEnd="true"
      android:layout_centerVertical="true"
      android:layout_marginEnd="8dp"
      android:orientation="horizontal"
      tools:ignore="RelativeOverlap">
      <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/level_label"
        android:textSize="20sp"
        android:textStyle="bold"
        tools:ignore="RelativeOverlap" />
      <TextView
        android:id="@+id/level_display"
        android:layout_width="40dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="32dp"
        android:gravity="end"
        android:text="@string/maxNumber"
        android:textSize="20sp"
        android:textStyle="bold" />
      <TextView
        android:id="@+id/score_label"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/score_label"
        android:textSize="20sp"
        android:textStyle="bold"
        tools:ignore="RelativeOverlap" />
      <TextView
        android:id="@+id/score_display"
        android:layout_width="40dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="16dp"
        android:gravity="end"
        android:text="@string/maxNumber"
        android:textSize="20sp"
        android:textStyle="bold" />
    </LinearLayout>
  </RelativeLayout>
</RelativeLayout>
Listing 7-11

Complete code for activity_main.xml

At this point, you should have something that looks like Figure 7-21.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig21_HTML.jpg
Figure 7-21

The app with game controls and pins

It’s starting to shape up, but we still need to fix that toolbar and the other widgets displayed on the top strip of the screen. We’ve already done this in the previous chapter so that this technique will be familiar. Listing 7-12 shows the code for the setToFullScreen() method.
private void setToFullScreen() {
  contentView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LOW_PROFILE
      | View.SYSTEM_UI_FLAG_FULLSCREEN
      | View.SYSTEM_UI_FLAG_LAYOUT_STABLE
      | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
      | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
      | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION);
}
Listing 7-12

setToFullScreen()

Enabling fullscreen mode is well documented in the Android Developer website; here’s the link for more information: https://developer.android.com/training/system-ui/immersive.

Listing 7-13 shows the annotated listing of MainActivity.
import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
public class MainActivity extends AppCompatActivity {
  ViewGroup contentView; ❶
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    getWindow().setBackgroundDrawableResource(R.mipmap.background);
    setContentView(R.layout.activity_main);
    contentView = (ViewGroup) findViewById(R.id.content_view);  ❷
    contentView.setOnTouchListener(new View.OnTouchListener() {
      @Override
      public boolean onTouch(View v, MotionEvent event) {  ❸
        if (event.getAction() == MotionEvent.ACTION_DOWN) {
          setToFullScreen();
        }
        return false;
      }
    });
  }
  @Override
  protected void onResume() {
    super.onResume();
    setToFullScreen();  ❹
  }
  private void setToFullScreen() {
    contentView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_OW_PROFILE
        | View.SYSTEM_UI_FLAG_FULLSCREEN
        | View.SYSTEM_UI_FLAG_LAYOUT_STABLE
        | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
        | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
        | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION);
  }
}
Listing 7-13

Annotated MainActivity

Declare the contentView variable as a member; we’ll use this on a couple of methods, so we need it available class-wide.

Get a reference to the FrameLayout container we defined earlier in activity_main. Store the returned value to the containerView variable.

The fullscreen setting is temporary. The screen may revert to displaying the toolbar later (e.g., when dialog windows are shown). We’re binding the setOnTouchListener() to the FrameLayout to allow the user to simply tap anywhere on the screen once to restore the full screen.

We’re calling the setToFullScreen() here in the onResume() lifecycle method. We want to set the screen to full when all of the View objects are already visible to the user.

Figure 7-22 shows the app in fullscreen mode.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig22_HTML.jpg
Figure 7-22

The app in full screen

Drawing the Balloons

The idea is to create a lot of balloons that will rise from the bottom to the top of the screen. We need to create the balloons programmatically. We can do this by creating a class that represents the balloon. We’ll write some logic that will create instances of the Balloon class and make them appear at random places at the bottom of the screen, but first things first, let’s create that Balloon class.

Right-click the project’s package, then choose NewJava Class, as shown in Figure 7-23.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig23_HTML.jpg
Figure 7-23

New Java class

In the window that follows, type the name of the class (Balloon) and type its superclass (AppCompatImageView), as shown in Figure 7-24.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig24_HTML.jpg
Figure 7-24

Create a new class

Listing 7-14 shows the code for the Balloon class.
import androidx.appcompat.widget.AppCompatImageView;
import android.content.Context;
import android.util.TypedValue;
import android.view.ViewGroup;
public class Balloon extends AppCompatImageView {
  public Balloon(Context context) { ❶
    super(context);
  }
  public Balloon(Context context, int color, int height, int level ) { ❷
    super(context);
    setImageResource(R.mipmap.balloon); ❸
    setColorFilter(color); ❹
    int width = height / 2;  ❺
    int dpHeight = pixelsToDp(height, context); ❻
    int dpWidth = pixelsToDp(width, context);
    ViewGroup.LayoutParams params =
        new ViewGroup.LayoutParams(dpWidth, dpHeight);
    setLayoutParams(params);
  }
  public static int pixelsToDp(int px, Context context) {
    return (int) TypedValue.applyDimension(
        TypedValue.COMPLEX_UNIT_DIP, px,
        context.getResources().getDisplayMetrics());
  }
}
Listing 7-14

Balloon class

This is the default constructor of the AppCompatImageView. We’ll leave this alone

We need a new constructor, one that takes in some parameters that we’ll need for the game. Overload the constructor and create one that takes in parameters for the balloon’s color, height and game level

Set the source for the image. Point it to the balloon image in the mipmap folders

The balloon image is just monochromatic gray. The setColorFilter() tints the image with any color you like. This is the reason why we want to parameterize the color

The image file of the balloon is set so that it’s twice as long as its width. To calculate the width of the balloon, we divide the height by 2

We want to calculate the device-independent pixels for the image; so, we created a static method in the Balloon class that does exactly that (see the implementation of pixelsToDp())

If you want to see this in action, you can modify the onTouch() listener of the contentView container in MainActivity such that, every time you touch the screen, a red balloon pops up exactly where you touched the screen. The code for that is shown in Listing 7-15.
contentView.setOnTouchListener(new View.OnTouchListener() {
  @Override
  public boolean onTouch(View v, MotionEvent event) {
    Balloon btemp = new Balloon(MainActivity.this, 0xFFFF0000, 100, 1); ❶
    btemp.setY(event.getY());   ❷
    btemp.setX(event.getX());   ❸
    contentView.addView(btemp); ❹
    if (event.getAction() == MotionEvent.ACTION_DOWN) {
      setToFullScreen();
    }
    return false;
  }
});
Listing 7-15

MainActivity’s onTouchListener

Create an instance of the Balloon class; pass the context, the color RED, an arbitrary height, and 1 (for the level, this isn’t important right now).

Set the Y coordinate where we want the Balloon object to show up.

Set the X coordinate.

Add the new Balloon object as a child to the View object; this is important because this makes the Balloon visible to us.

At this point, every time you click the screen, a red balloon shows up. We need to mix up the colors of the balloons to make it more interesting. Let’s use at least three colors: red, green, and blue. We can look up the hex values of these colors, or we can use the Color class in Android. To get the red color, we can write something like this:
Color.argb(255, 255, 0, 0);
For blue and green, it would be as follows:
Color.argb(255, 0, 255, 0);
Color.argb(255, 0, 0, 255);
A simple solution to rotate the colors is to set up an array of three elements, where each element contains a color value. Listing 7-16 shows the partial code for this task.
private int[] colors = new int[3];
@Override
protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  // ...
  colors[0] = Color.argb(255, 255, 0, 0);
  colors[1] = Color.argb(255, 0, 255, 0);
  colors[2] = Color.argb(255, 0, 0, 255);
}
Listing 7-16

Array of colors (this goes into the MainActivity)

Next, we set up a method that returns a random number between 0 and 2. We’ll make this our random selector for color. Listing 7-17 shows this code.
private static int nextColor() {
  int max = 2;
  int min = 0;
  int retval = 0;
  Random random = new Random();
  retval = random.nextInt((max - min) + 1) + min;
  return retval;
}
Listing 7-17

nextColor() method

Next, we modify that part of our code in MainActivity when we create the Balloon (inside the onTouch() method) and assign it a color; now, we will assign it a random color. Listing 7-18 shows that code.
int curColor = colors[nextColor()];
Balloon btemp = new Balloon(MainActivity.this, curColor, 100, 1);
btemp.setY(event.getY());
btemp.setX(event.getX());
contentView.addView(btemp);
Listing 7-18

Assigning a random color

Figure 7-25 shows the app randomizing the colors of the balloons.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig25_HTML.jpg
Figure 7-25

Random colors

Making the Balloons Float

To make the balloons float from the bottom to the top, we will use a built-in class in Android SDK. We won’t micromanage the position of the balloon as it rises to the top of the screen.

The ValueAnimator class (Android .animation.ValueAnimator) is essentially a timing engine for running animations. It calculates animated values and then sets them on the target objects.

Since we want to animate each balloon, we’ll put the animation logic inside the Balloon class; let’s add a new method named release() where we will put the necessary code to make the balloon float. Listing 7-19 shows the code.
private BalloonListener listener;
// some other statements  ...
listener = new BalloonListener(this);
// some other statements ...
public void release(int scrHeight, int duration) { ❶
  animator = new ValueAnimator();  ❷
  animator.setDuration(duration);  ❸
  animator.setFloatValues(scrHeight, 0f);  ❹
  animator.setInterpolator(new LinearInterpolator()); ❺
  animator.setTarget(this);  ❻
  animator.addListener(listener);
  animator.addUpdateListener(listener); ❼
  animator.start(); ❽
}
Listing 7-19

release() method in the Balloon class

The release() method takes two arguments; the first one is the height of the screen (we need this for the animation), and the second one is duration; we need this later for the levels. As the level increases, the faster the balloon will rise.

Create the Animator object.

This sets the duration of the animation. The higher this value is, the longer the animation.

This sets the float values that will be animated between. We want to animate from the bottom of the screen to the top; hence, we pass 0f and the screen height.

We set the time interpolator used in calculating the elapsed fraction of this animation. The interpolator determines whether the animation runs with linear or nonlinear motion, such as acceleration and deceleration. In our case, we want a linear acceleration, so we pass an instance of the LinearInterpolator.

The target of the animation is the specific instance of a Balloon, hence this.

The animation has a life cycle. We can listen to these updates by adding some Listener objects. We will implement these listeners in a little while.

Start the animation.

Create a new class (on the same package) and name it BalloonListener.java; Listing 7-20 shows the code for the BalloonListener.
import android.animation.Animator;
import android.animation.ValueAnimator;
public class BalloonListener implements ❶
    Animator.AnimatorListener,
    ValueAnimator.AnimatorUpdateListener{
  Balloon balloon;
  public BalloonListener(Balloon balloon) {
    this.balloon = balloon;  ❷
  }
  @Override
  public void onAnimationUpdate(ValueAnimator valueAnimator) {
    balloon.setY((float) valueAnimator.getAnimatedValue()); ❸
  }
  // some other lifecycle methods ...
}
Listing 7-20

BalloonListener.java

We’re interested in the lifecycle methods of the Animation; hence, we implement Animator.AnimatorListener and ValueAnimator.AnimatorUpdateListener.

We need a reference to the Balloon object; hence, we take it in as a parameter when this listener object gets created.

When the ValueAnimator updates its values, we will set the Y position of the balloon instance to this value.

In MainActivity (where we create an instance of the Balloon), we need to calculate the screen height. Listing 7-21 shows the annotated code that will accomplish that.
ViewTreeObserver viewTreeObserver = contentView.getViewTreeObserver(); ❶
if (viewTreeObserver.isAlive()) { ❷
  viewTreeObserver.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {  ❸
    @Override
    public void onGlobalLayout() {
      contentView.getViewTreeObserver().removeOnGlobalLayoutListener(this); ❹
      scrWidth = contentView.getWidth(); ❺
      scrHeight = contentView.getHeight();
    }
  });
}
Listing 7-21

Calculate the screen’s height and width

Get an instance of the ViewTreeObserver.

We can only work with this observer when it’s alive; so, we wrap the whole logic inside an if-statement.

We want to be notified when the global layout state or the visibility of views within the view tree changes.

We want to get notified only once; so, once the onGlobalLayout() method is called, we remove the listener.

Now, we can get the screen’s height and width.

Listing 7-22 shows MainActivity with the code to calculate the screen’s height and width.
public class MainActivity extends AppCompatActivity {
  ViewGroup contentView;
  private static  String TAG;
  private int[] colors = new int[3];
  private int scrWidth; ❶
  private int scrHeight;
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    TAG = getClass().getName();
    // other statements ...
    contentView = (ViewGroup) findViewById(R.id.content_view);
    contentView.setOnTouchListener(new View.OnTouchListener() {
      @Override
      public boolean onTouch(View v, MotionEvent event) {
        Log.d(TAG, "onTouch");
        int curColor = colors[nextColor()];
        Balloon btemp = new Balloon(MainActivity.this, curColor, 100, 1);
        btemp.setY(scrHeight); ❷
        btemp.setX(event.getX());
        contentView.addView(btemp);
        btemp.release(scrHeight, 4000); ❸
        Log.d(TAG, "Balloon created");
        if (event.getAction() == MotionEvent.ACTION_DOWN) {
          setToFullScreen();
        }
        return false;
      }
    });
  }
  @Override
  protected void onResume() {
    super.onResume();
    setToFullScreen(); ❹
    ViewTreeObserver viewTreeObserver = contentView.getViewTreeObserver(); ❺
    if (viewTreeObserver.isAlive()) {
      viewTreeObserver.addOnGlobalLayoutListener(new
                      ViewTreeObserver.OnGlobalLayoutListener() {
        @Override
        public void onGlobalLayout() {
           contentView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
          scrWidth = contentView.getWidth();
          scrHeight = contentView.getHeight();
        }
      });
    }
  }
}
Listing 7-22

MainActivity

Create member variables scrHeight and scrWidth.

Change the value of Y coordinate for the Balloon instance. Instead of showing the Y position of the Balloon where the click occurred, let’s start the Y position of the Balloon at the bottom of the screen.

Call the release() method of the Balloon. We would have calculated the screen height by the time we make this call. The second argument is hardcoded for now (duration), which means the Balloon will take about 4 seconds to rise to the top of the screen.

Before we calculate the screen height and width, it’s very important that we already called setToFullScreen(); that way, we’ve got an accurate set of dimensions.

Put the code to calculate the screen’s height and width on the callback when all the View objects are already visible to the user; that’s the onResume() method.

At this point, if you run the app, a Balloon object will rise from the bottom to the top of the screen whenever you click anywhere on the screen (Figure 7-26).
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig26_HTML.jpg
Figure 7-26

Balloons rising to the top

Launching the Balloons

Now that we can make balloons rise to the top one at a time, we need to figure out how to launch a couple of balloons that resembles a level of a game. Right now, the balloons appear on the screen in response to the user’s click; this isn’t how we want to play the game. We need to make some changes.

What we want is for the player to click a button, then start the gameplay. When the button is first clicked, that automatically gets the user into the first level. The levels of the game aren’t complicated; as the levels rise, we’ll simply increase the speed of the balloons.

To launch the balloons, we need to do the following:
  1. 1.

    Make the Button in activity_main.xml respond to click events.

     
  2. 2.

    Create a new method in MainActivity that will contain all the code needed to start a level.

     
  3. 3.

    Write a loop that will launch several balloons.

     
  4. 4.

    Randomize the X position of the Balloons as they are created.

     
To make the Button respond to click events, we need to bind it to an OnClickListener object, as shown in Listing 7-23.
Button btn = (Button) findViewById(R.id.btn);
btn.setOnClickListener(new View.OnClickListener() {
  @Override
  public void onClick(View view) {
    // start the level
    // when this is clicked
  }
});
Listing 7-23

Binding the Button to an onClickListener

The code to start a level is shown in Listing 7-24.
private void startLevel() {
  // we'll fill this codes later
}
Listing 7-24

startLevel() in MainActivity

We need to refactor the code to launch a single balloon. Right now, we’re doing it inside the onTouchListener. We want to enclose this logic in a method. Listing 7-25 shows the launchBalloon() method in MainActivity.
public void launchBalloon(int xPos) { ❶
  int curColor = colors[nextColor()];
  Balloon btemp = new Balloon(MainActivity.this, curColor, 100, 1);
  btemp.setY(scrHeight);
  btemp.setX(xPos); ❷
  contentView.addView(btemp);
  btemp.release(scrHeight, 3000);
  Log.d(TAG, "Balloon created");
}
Listing 7-25

launchBalloon()

The method takes an int parameter. This will be the X position of the Balloon on the screen.

Set the horizontal position of the Balloon.

We want to launch the balloons in the background; you don’t want to do these things in the main UI thread because that will affect the game’s responsiveness. We don’t want the game to feel janky. So, we’ll write the looping logic in a Thread. Listing 7-26 shows the code for this Thread class.
class LevelLoop extends Thread { ❶
  int balloonsLaunched = 0;
  public void run() {
    while (balloonsLaunched <= 15) { ❷
      balloonsLaunched++;
      Random random = new Random(new Date().getTime());
      final int xPosition = random.nextInt(scrWidth - 200); ❸
      try {
        Thread.sleep(1000);  ❹
      }
      catch(InterruptedException e) {
        Log.e(TAG, e.getMessage());
      }
      // need to wrap this on runOnUiThread
      runOnUiThread(new Thread() {
        public void run() {
          launchBalloon(xPosition);  ❺
        }
      });
    }
  }
}
Listing 7-26

LevelLoop (implemented as an inner class in MainActivity)

LevelLoop is an inner class in MainActivity. Implementing it as an inner class lets us access the outer class’ (MainActivity) member variables and methods (which will be handy).

The loop will stop when we’ve launched 15 balloons. The number of balloons to launch is hardcoded right now, but we’ll refactor this later.

Get a random number to pick an X coordinate for the Balloon.

Let’s introduce a delay; if you don’t introduce the delay, all 15 balloons can appear and rise to the top at the same time. Right now, the delay is hardcoded; we’ll refactor this later. We need to vary this according to the level. By the way, Thread.sleep() throws the InterruptedException; that’s why we need to wrap this in a try-catch block.

Finally, call the launchBalloon() method of the outer class. We need to wrap this call in a runOnUiThread() method because it’s illegal for a background process to make calls on UI elements; UI elements are rendered on the main thread (otherwise known as the UI thread). If you need to make a call on objects that are on the UI thread while you are running in the background, you’ll need to wrap that call on a runOnUiThread() method like what we did here.

At this point, every time you click the “Play” button, the game will launch a series of 15 balloons that will rise to the top of the screen; however, the game has no concept of levels yet. No matter how many times you click “Play,” the speed of the rising balloons remains constant. Let’s fix that in the next section.

Handling Game Levels

To introduce levels, let’s create a member variable in MainActivity to hold the value of the levels, and every time we call the startLevel() method, we increment that variable by 1. Listing 7-27 shows the code for these changes.
private int level; ❶
// other statements ...
@Override
protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  // other statements ...
  levelDisplay = (TextView) findViewById(R.id.level_display); ❷
  scoreDisplay = (TextView) findViewById(R.id.score_display); ❸
}
private void startLevel() {
  level++; ❹
  new LevelLoop(level).start(); ❺
  levelDisplay.setText(String.format("%s", level)); ❻
}
Listing 7-27

Preparing the levels

Declare level as a member variable.

Get a reference to the TextView object that displays the current level.

While we’re at it, also get a reference to the TextView object that displays the current score.

Increment the level variable every time the startLevel() method is called.

Let’s pass the level variable to the LevelLoop object (we need to refactor the LevelLoop class, so it becomes aware of the game level).

Let’s display the current level.

Next, let’s refactor the LevelLoop class to make it sensitive to the current game level. Listing 7-28 shows these changes.
class LevelLoop extends Thread {
  private int shortDelay = 500;  ❶
  private int longDelay = 1_500;
  private int maxDelay;
  private int minDelay;
  private int delay;
  private int looplevel;
  int balloonsLaunched = 0;
  public LevelLoop(int argLevel) { ❷
    looplevel = argLevel;
  }
  public void run() {
    while (balloonsLaunched < 15) {
      balloonsLaunched++;
      Random random = new Random(new Date().getTime());
      final int xPosition = random.nextInt(scrWidth - 200);
      maxDelay = Math.max(shortDelay, (longDelay - ((looplevel -1)) * 500)); ❸
      minDelay = maxDelay / 2;
      delay = random.nextInt(minDelay) + minDelay;
      Log.i(TAG, String.format("Thread delay = %d", delay));
      try {
        Thread.sleep(delay); ❹
      }
      catch(InterruptedException e) {
        Log.e(TAG, e.getMessage());
      }
      // need to wrap this on runOnUiThread
      runOnUiThread(new Thread() {
        public void run() {
          launchBalloon(xPosition);
        }
      });
    }
  }
}
Listing 7-28

LevelLoop

Let’s introduce the variables longDelay and shortDelay, which hold the integer values for the longest possible delay (in milliseconds) and the shortest possible delay, respectively.

Refactor the constructor to accept a level parameter. Assign this parameter to the member variable looplevel.

This bit of math calculates the delay (which is now affected by the level). The delay won’t be lower than shortDelay nor will it be higher than longDelay.

Use the calculated delay in the Thread.sleep() method.

Pop the Balloons

To score points, the player has to touch the balloons, thereby popping them before they get to the top of the screen. When a balloon gets to the top of the screen, it also pops, but the player doesn’t score a point; in fact, the player loses a pin when that happens.

To pop a balloon, we need to set up a touch listener for the Balloon, then inform MainActivity that the player popped the balloon; we need to inform MainActivity because
  1. 1.

    In MainActivity, we will update the score and the status of how many pins are left.

     
  2. 2.

    Also in MainActivity, we will remove the Balloon from the ViewGroup, regardless of how it got popped, whether the player popped it or the balloon got away.

     
To do this, we need to set up an interface between the Balloon class and MainActivity. Let’s create an interface and add it to the project. Creating an interface in Android Studio is very similar to how we create classes. Use the context menu; right-click the project’s package, then choose NewJava Class, as shown in Figure 7-27.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig27_HTML.jpg
Figure 7-27

New Java class

In the window that follows, type the name of the interface (PopListener) and choose Interface as the kind (shown in Figure 7-28).
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig28_HTML.jpg
Figure 7-28

New interface

The PopListener interface will only have one method (shown in Listing 7-29).
public interface PopListener {
 void popBalloon(Balloon bal, boolean isTouched);
}
Listing 7-29

PopListener interface

The first parameter (bal) refers to a specific instance of a Balloon. We need this reference because this is what we’ll remove from the ViewGroup. Removing it from the ViewGroup makes it disappear from the screen. The second parameter will tell us if the balloon popped because the player got it, in which case this parameter will be true, or if it popped because it went all the way to the top, in which case, the parameter will be false.

Now we make a quick change to MainActivity, as shown in Listing 7-30.
public class MainActivity extends AppCompatActivity
    implements PopListener { ❶
  @Override
  public void popBalloon(Balloon bal, boolean isTouched) { ❷
    contentView.removeView(bal); ❸
    if(isTouched) {
      userScore++; ❹
      scoreDisplay.setText(String.format("%d", userScore)); ❺
    }
  }
}
Listing 7-30

MainActivity

Implement the PopListener interface.

Implement the actual popBalloon() method.

This code removes a specific instance of a Balloon in the ViewGroup.

Now we can increment the player’s score.

This will display the score of the player.

Then we make adjustments on the Balloon class; Listing 7-31 shows these changes.
public class Balloon extends AppCompatImageView
    implements View.OnTouchListener { ❶
  private ValueAnimator animator;
  private BalloonListener listener;
  private boolean isPopped;  ❷
  private PopListener mainactivity; ❸
  private final String TAG = getClass().getName();
  public Balloon(Context context) {
    super(context);
  }
  public Balloon(Context context, int color, int height, int level ) {
    super(context);
    mainactivity = (PopListener) context; ❹
    // other statements ...
    setOnTouchListener(this);  ❺
  }
  // other methods ...
  @Override
  public boolean onTouch(View view, MotionEvent motionEvent) {
    Log.d(TAG, "TOUCHED");
    if(!isPopped) {
      mainactivity.popBalloon(this, true);
      isPopped = true;
      animator.cancel();
    }
    return true;
  }
  public void pop(boolean isTouched) { ❻
    mainactivity.popBalloon(this, isTouched); ❼
  }
  public boolean isPopped() {  ❽
    return isPopped;
  }
}
Listing 7-31

Balloon class

Implement the View.OnTouchListener on the Balloon class. We’ll make this class the listener for touch events.

The isPopped variable holds the state of any particular balloon, whether popped or not.

Create a reference to MainActivity (which implements the PopListener interface).

In the Balloon’s constructor, cast the Context object to PopListener and assign it to the mainactivity variable.

Set the onTouchListener for this Balloon instance.

Create a utility function named pop(). We’re making it public because we’ll need to call this method from the BalloonListener class later on.

Create a utility function named isPopped(); we will also call this method from the BalloonListener class.

At this point, you can play the game with limited functionality. When you click “Play,” a set of Balloons floats to the top; clicking a balloon removes it from the ViewGroup. When a balloon reaches the top, it also gets removed from the ViewGroup.

Managing the Pins

When a balloon gets away from the player, we want to update the pushpin images on top of the screen. For every missed balloon, we want to display a broken pushpin image. The code we need to change is in MainActivity; so, let’s implement that change.

We can start by declaring two member variables on MainActivity.
  • numberOfPins = 5;—The number of pins in our layout.

  • pinsUsed;—Each time a balloon gets away, we increment this variable.

Let’s also create an ArrayList to hold the pushpin images. We want to put them in an ArrayList so we can reference the pushpin images programmatically. Creating and populating the ArrayList with the pushpin images can be done with the code in Listing 7-32. This code can be written inside the onCreate() method of MainActivity.
private ArrayList<ImageView> pinImages = new ArrayList<>();
pinImages.add((ImageView) findViewById(R.id.pushpin1));
pinImages.add((ImageView) findViewById(R.id.pushpin2));
pinImages.add((ImageView) findViewById(R.id.pushpin3));
pinImages.add((ImageView) findViewById(R.id.pushpin4));
pinImages.add((ImageView) findViewById(R.id.pushpin5));
Listing 7-32

Pushpin images in an ArrayList

We’ve already got the logic to handle the missed balloons inside the popBalloon() method. We already know how to handle the case when the player pops the Balloon; all we need to do is add some more logic to the existing if-else condition. Listing 7-33 shows us that code.
public void popBalloon(Balloon bal, boolean isTouched) {
  contentView.removeView(bal);
  if(isTouched) {
    userScore++;
    scoreDisplay.setText(String.format("%d", userScore));
  }
  else { ❶
    pinsUsed++; ❷
    if (pinsUsed <= pinImages.size() ) { ❸
      pinImages.get(pinsUsed -1).setImageResource(R.drawable.pin_broken); ❹
      Toast.makeText(this, "Ouch!",Toast.LENGTH_SHORT).show(); ❺
    }
    if(pinsUsed == numberOfPins) { ❻
      gameOver();
    }
  }
}
private void gameOver() {
  // TODO: implement GameOver method
  Toast.makeText(this, "Game Over", Toast.LENGTH_LONG).show();
}
Listing 7-33

popBalloon()

If isTouched is false, that means the balloon got away from the player.

Increment the pinsUsed variable. For every missed balloon, we increment this variable.

Let’s check if pinsUsed is less than or equal to the size of the ArrayList which contains the pushpin images (which has five elements); if this expression is true, that means it isn’t game over yet, the player still has some pins to spare, and we can continue the gameplay.

This code replaces the image of the pushpin; it sets the image to that of the broken pin.

We display a simple Toast message to the player. A Toast message is a small pop-up that appears at the bottom of the screen, then fades away from view.

Let’s check if the player has used up all five pins. If they have, we call the gameOver() method, which we still have to implement.

When the Game is Over

When the game is over, we need to do some cleanup; at the very least, we have to reset the pushpin images—which is easy enough to do. Listing 7-34 should accomplish that job.
for (ImageView pin: pinImages) {
  pin.setImageResource(R.drawable.pin);
}
Listing 7-34

Resetting the pushpin images

We also need to reset a couple of counters. To do these cleanups, let’s reorganize MainActivity a little bit. Start with implementing the gameOver() method, as shown in Listing 7-35.
private void gameOver() {
  isGameStopped = true;
  Toast.makeText(this, "Game Over", Toast.LENGTH_LONG).show();
  btn.setText("Play game");
}
Listing 7-35

gameOver()

We’re simply displaying a Toast to the player, announcing the game over message. We’re also resetting the text of the Button. You might have noticed the isGameStopped variable; that’s another member variable we need to create to help us manage some rudimentary game states.

Next, let’s add another method called finishLevel(), so we can group some actions we need to take when the player finishes a level; the code for that is in Listing 7-36.
private void finishLevel() {
  Log.d(TAG, "FINISH LEVEL");
  String message = String.format("Level %d finished!", level);
  Toast.makeText(this, message, Toast.LENGTH_LONG).show(); //  ❶
  level++; ❷
  updateGameStats(); ❸
  btn.setText(String.format("Start level %d", level)); ❹
  Log.d(TAG, String.format("balloonsLaunched = %d", balloonsLaunched));
  balloonsPopped = 0; ❺
}
Listing 7-36

finishLevel()

Tell the player that the level is finished.

Increment the level variable.

We haven’t implemented this method yet, but you could probably guess what it will do. It will simply display the current score and the current level.

Change the text of the Button to one that reflects the next level.

We’re resetting the balloonsPopped variable to zero. We also need to create this member variable. It will keep track of all the Balloons that got popped. We will use this to determine if the level is already finished.

Listing 7-37 shows the code for the updateGameStats() method.
private void updateGameStats() {
  levelDisplay.setText(String.format("%s", level));
  scoreDisplay.setText(String.format("%s", userScore));
}
Listing 7-37

updateGameStats()

Now, we need to know when the level is finished. We never bothered with this before because we simply let the LevelLoop thread do its work of launching the balloons, but now we need to manage some game states. There are a couple of places in MainActivity where we can signal the end of the level. We can do it inside the LevelLoop thread. As soon as the while loop ends, that should signal the end of the level; but the game might feel out of sync if we put it there. The Toast messages might appear while some balloons are still being animated. We will call the finishLevel() inside the popBalloon() method instead.

If we simply count the number of Balloons that gets popped—which is everything, because every balloon gets popped one way or another—compare it with the number of balloons we launch per level; when the two variables are equal, that should signal the end of the level. Listing 7-38 shows that implementation.
@Override
public void popBalloon(Balloon bal, boolean isTouched) {
  balloonsPopped++;
  contentView.removeView(bal);
  if(isTouched) {
    userScore++;
    scoreDisplay.setText(String.format("%d", userScore));
  }
  else {
    pinsUsed++;
    if (pinsUsed <= pinImages.size() ) {
      pinImages.get(pinsUsed -1).setImageResource(R.drawable.pin_broken);
      Toast.makeText(this, "Ouch!",Toast.LENGTH_SHORT).show();
    }
    if(pinsUsed == numberOfPins) {
      gameOver();
    }
  }
  if (balloonsPopped == balloonsPerLevel) {
    finishLevel();
  }
}
Listing 7-38

popBalloon()

Next, let’s move to the startLevel() method. The refactored code is shown in Listing 7-39.
private void startLevel() {
  if (isGameStopped) { ❶
    isGameStopped = false; ❷
    startGame(); ❸
  }
  updateGameStats(); ❹
  new LevelLoop(level).start();
}
Listing 7-39

startLevel()

Let’s check for some game state. This will be false the very first time a player starts the game. This gets reset in the gameOver() method. If this condition is true, it means we’re starting a new game.

Let’s set the value of isGameStopped to false since we have started a new game.

Call the startGame() method. We will implement this shortly.

Update the game statistics.

Next, implement the startGame() method; Listing 7-40 shows us how.
private void startGame() {
  // reset the scores
  userScore = 0;
  level = 1;
  updateGameStats();
  //reset the pushpin images
  for (ImageView pin: pinImages) {
    pin.setImageResource(R.drawable.pin);
  }
}
Listing 7-40

startGame() method

That should take care of some basic housekeeping.

Audio

Most games use music in the background to enhance the player’s experience. These games also use sound effects for a more immersive feel. Our little game will use both. We will play a background music when the game starts, and we’ll also play a sound effect when a Balloon gets popped.

I got the background music and the popping sound effect from YouTube Audio Library; feel free to source your preferred background music.

Once you’ve procured the audio files, you need to add them to the project; firstly, you need to create a raw folder in the app/res directory. You can do that with the context menu. Right-click app/res, then choose NewFolderRaw Resources Folder, as shown in Figure 7-29.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig29_HTML.jpg
Figure 7-29

New Resources Folder

In the window that follows, click Finish, as shown in Figure 7-30.
../images/340874_4_En_7_Chapter/340874_4_En_7_Fig30_HTML.jpg
Figure 7-30

New Android Component

Next, right-click the raw folder. Depending on what OS you’re using, choose either Reveal in Finder or Show in Explorer.

You can now drag and drop the audio files in the raw folder.

To play the background music, we will need a MediaPlayer object. This object is built-in in Android SDK. We simply need to import it to our Java source file. The following are the key method calls for the MediaPlayer object. Listing 7-41 shows the important APIs we will use.
import android.media.MediaPlayer
MediaPlayer mplayer;
mplayer = MediaPlayer.create(ctx.getApplicationContext(), R.raw.ngoni); ❶
mplayer.setVolume(07.f, 0.7f); ❷
mplayer.setLooping(true); ❸
mplayer.start(); ❹
mplayer.pause() ❺
Listing 7-41

Key method calls on the MediaPlayer object

This statement creates an instance of MediaPlayer. It takes two arguments; the first one is a Context object, and the second argument is the name of the resource file in the raw folder (ngoni.mp3). We are specifying a resource file here, so there is no need to add the .mp3 extension.

The setVolume() method takes two arguments. The first one is a float value to specify the left channel volume, and the second one is for the volume of the right channel. The range of these values is from 0.0 to 1.0. As you can see, I specified a 70% volume. In an actual game, you might want to store these values in a preferences file and let the user control it.

I’d like the music to keep on playing. I’m setting it on auto-repeat here.

This will start playing the music.

This will pause the music.

To play the popping sound for the Balloon, we’ll use the SoundPool object. The popping sound is provided as a very short audio file that will be used over and over (every time we pop a balloon). These kinds of sounds are best managed using the SoundPool object.

There’s a bit of setup required before you can use a SoundPool object; Listing 7-42 shows this setup.
public Audio(Activity activity) { ❶
  AudioManager audioManager = (AudioManager)
        activity.getSystemService(Context.AUDIO_SERVICE);
  float actVolume = (float)
        audioManager.getStreamVolume(AudioManager.STREAM_MUSIC); ❷
  float maxVolume = (float)
        audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
  volume = actVolume / maxVolume;
  activity.setVolumeControlStream(AudioManager.STREAM_MUSIC); ❸
  AudioAttributes audioAttrib = new AudioAttributes.Builder() ❹
      .setUsage(AudioAttributes.USAGE_GAME)
      .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
      .build();
  soundPool = new SoundPool.Builder()
              .setAudioAttributes(audioAttrib)
              .setMaxStreams(6)
              .build();
  soundPool.setOnLoadCompleteListener(new SoundPool.OnLoadCompleteListener() { ❺
    @Override
    public void onLoadComplete(SoundPool soundPool, int sampleId, int status) {
      Log.d(TAG, "SoundPool is loaded");
      isLoaded = true;
    }
  });
  soundId = soundPool.load(activity, R.raw.pop, 1); ❻
}
public void playSound() {
  if (isLoaded) {
    soundPool.play(soundId, volume, volume, 1, 0, 1f); ❼
  }
  Log.d(TAG, "playSound");
}
Listing 7-42

SoundPool

Setting up the SoundPool and the AudioManager is usually done on the constructor. We need to pass an Activity instance (which will be MainActivity), so we can get a reference to the audio service.

We will use the getStreamVolume() and getStreamMaxVolume() to determine how loud we want our sound effect to be.

This binds the volume control to MainActivity.

We need to set some attributes to build the sound pool. This method of building the sound pool is for Android versions 5.0 and up (Lollipop).

The sound is loaded asynchronously. We need to set up a listener, so we get notified when it’s loaded.

Now we get to load the sound file from our raw folder.

This line plays the sound. This is what we will call in the popBalloon() method.

We’re going to put all of this code in a separate class; we’ll name it the Audio class. Create a new Java class named Audio. You can do that by right-clicking the project’s package, then choosing NewJava Class, as we did before. Listing 7-43 shows the full code for the Audio class.
import android.app.Activity;
import android.content.Context;
import android.media.AudioAttributes;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.media.SoundPool;
import android.util.Log;
public class Audio {
  private final int soundId;
  private MediaPlayer mplayer;
  private float volume;
  private SoundPool soundPool;
  private boolean isLoaded;
  private final String TAG = getClass().getName();
  public Audio(Activity activity) {
    AudioManager audioManager = (AudioManager) activity.getSystemService(Context.AUDIO_SERVICE);
    float actVolume = (float) audioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
    float maxVolume = (float) audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
    volume = actVolume / maxVolume;
    activity.setVolumeControlStream(AudioManager.STREAM_MUSIC);
    AudioAttributes audioAttrib = new AudioAttributes.Builder()
        .setUsage(AudioAttributes.USAGE_GAME)
        .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
        .build();
    soundPool = new SoundPool.Builder()
                    .setAudioAttributes(audioAttrib)
                    .setMaxStreams(6)
                    .build();
    soundPool.setOnLoadCompleteListener(new SoundPool.OnLoadCompleteListener() {
      @Override
      public void onLoadComplete(SoundPool soundPool, int sampleId, int status) {
        Log.d(TAG, "SoundPool is loaded");
        isLoaded = true;
      }
    });
    soundId = soundPool.load(activity, R.raw.pop, 1);
  }
  public void playSound() {
    if (isLoaded) {
      soundPool.play(soundId, volume, volume, 1, 0, 1f);
    }
    Log.d(TAG, "playSound");
  }
  public void prepareMediaPlayer(Context ctx) {
    mplayer = MediaPlayer.create(ctx.getApplicationContext(), R.raw.ngoni);
    mplayer.setVolume(05.f, 0.5f);
    mplayer.setLooping(true);
  }
  public void playMusic() {
    mplayer.start();
  }
  public void stopMusic() {
    mplayer.stop();
  }
  public void pauseMusic() {
    mplayer.pause();
  }
}
Listing 7-43

Audio class

Now we can add some sounds to the app. In the MainActivity class, we need to create a member variable of type Audio, like this:
Audio audio;
Then, in the onCreate() method, we instantiate the Audio class and call the prepareMediaPlayer() method, as shown in the following:
audio = new Audio(this);
audio.prepareMediaPlayer(this);
We want to play the music only when the game is in play; so, in MainActivity’s startGame() method, we add the following statement:
audio.playMusic();
When the game is not at play anymore, we want the music to stop; so, in the gameOver() method, we add this statement:
audio.pauseMusic();
Finally, in the popBalloon() method, add the following statement:
audio.playSound();

Final Touches

If you’ve been following the coding exercise (and running the game), you may have noticed that even after the game is over, you can still see some balloons flying around; you can thank the background thread for that. Even when all the five pins have been used up, the level is still active, and we still see some balloons being launched. To handle that, we can do the following:
  1. 1.

    Keep track of all the balloons being released per level. We can do this using an ArrayList. Every time we launch a balloon, we add it to the list.

     
  2. 2.

    As soon as a Balloon is popped, we take it out of the list.

     
  3. 3.

    If we reach game over, we go through all the remaining Balloon objects in the ArrayList and set their status to popped.

     
  4. 4.

    Lastly, remove all the remaining Balloon objects from the ViewGroup.

     
First, let’s declare an ArrayList (as a member variable on MainActivity) to hold all the references to all Balloons that will be launched per level. The following code accomplishes that:
private ArrayList<Balloon> balloons = new ArrayList<>();
Next, in the launchBalloon() method, we insert a statement that adds a Balloon object to the ArrayList, like this:
balloons.add(btemp);
Next, in the gameOver() method, we add a logic that will loop through all the remaining Balloons in the ArrayList, set their popped status to true, and also remove the Balloon instance from the ViewGroup (the code is shown in Listing 7-44).
private void gameOver() {
  isGameStopped = true;
  Toast.makeText(this, "Game Over", Toast.LENGTH_LONG).show();
  btn.setText("Play game");
  for (Balloon bal : balloons) {
    bal.setPopped(true);
    contentView.removeView(bal);
  }
  balloons.clear();
  audio.pauseMusic();
}
Listing 7-44

gameOver() method

Finally, we need to add the setPopped() method to the Balloon class, as shown in Listing 7-45.
public void setPopped(boolean b) {
  isPopped = true;
}
Listing 7-45

setPopped() method in the Balloon class

That should do it. The final code listing we will see in this chapter is the complete code for MainActivity. It may be difficult to keep things straight after all the changes we made to MainActivity; so, to provide as a reference, Listing 7-46 shows MainActivity’s complete code.
import android.graphics.Color;
import android.os.Bundle;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import java.util.ArrayList;
import java.util.Date;
import java.util.Random;
public class MainActivity extends AppCompatActivity
    implements PopListener {
  ViewGroup contentView;
  private static  String TAG;
  private int[] colors = new int[3];
  private int scrWidth;
  private int scrHeight;
  private int level = 1;
  private TextView levelDisplay;
  private TextView scoreDisplay;
  private int numberOfPins = 5;
  private int pinsUsed;
  private int balloonsLaunched;
  private int balloonsPerLevel = 8;
  private int balloonsPopped = 0;
  private boolean isGameStopped = true;
  private ArrayList<ImageView> pinImages = new ArrayList<>();
  private ArrayList<Balloon> balloons = new ArrayList<>();
  private int userScore;
  Button btn;
  Audio audio;
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    TAG = getClass().getName();
    getWindow().setBackgroundDrawableResource(R.mipmap.background);
    setContentView(R.layout.activity_main);
    colors[0] = Color.argb(255, 255, 0, 0);
    colors[1] = Color.argb(255, 0, 255, 0);
    colors[2] = Color.argb(255, 0, 0, 255);
    contentView = (ViewGroup) findViewById(R.id.content_view);
    levelDisplay = (TextView) findViewById(R.id.level_display);
    scoreDisplay = (TextView) findViewById(R.id.score_display);
    pinImages.add((ImageView) findViewById(R.id.pushpin1));
    pinImages.add((ImageView) findViewById(R.id.pushpin2));
    pinImages.add((ImageView) findViewById(R.id.pushpin3));
    pinImages.add((ImageView) findViewById(R.id.pushpin4));
    pinImages.add((ImageView) findViewById(R.id.pushpin5));
    btn = (Button) findViewById(R.id.btn);
    btn.setOnClickListener(new View.OnClickListener() {
      @Override
      public void onClick(View view) {
        startLevel();
      }
    });
    contentView.setOnTouchListener(new View.OnTouchListener() {
      @Override
      public boolean onTouch(View v, MotionEvent event) {
        if (event.getAction() == MotionEvent.ACTION_DOWN) {
          setToFullScreen();
        }
        return false;
      }
    });
    audio = new Audio(this);
    audio.prepareMediaPlayer(this);
  }
  @Override
  protected void onResume() {
    super.onResume();
    updateGameStats();
    setToFullScreen();
    ViewTreeObserver viewTreeObserver = contentView.getViewTreeObserver();
    if (viewTreeObserver.isAlive()) {
      viewTreeObserver.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
        @Override
        public void onGlobalLayout() {
          contentView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
          scrWidth = contentView.getWidth();
          scrHeight = contentView.getHeight();
        }
      });
    }
  }
  public void launchBalloon(int xPos) {
    balloonsLaunched++;
    int curColor = colors[nextColor()];
    Balloon btemp = new Balloon(MainActivity.this, curColor, 100,  level);
    btemp.setY(scrHeight);
    btemp.setX(xPos);
    balloons.add(btemp);
    contentView.addView(btemp);
    btemp.release(scrHeight, 5000);
    Log.d(TAG, "Balloon created");
  }
   private void startLevel() {
    if (isGameStopped) {
      isGameStopped = false;
      startGame();
    }
    updateGameStats();
    new LevelLoop(level).start();
  }
  private void finishLevel() {
    Log.d(TAG, "FINISH LEVEL");
    String message = String.format("Level %d finished!", level);
    Toast.makeText(this, message, Toast.LENGTH_LONG).show();
    level++;
    updateGameStats();
    btn.setText(String.format("Start level %d", level));
    Log.d(TAG, String.format("balloonsLaunched = %d", balloonsLaunched));
    balloonsPopped = 0;
  }
  private void updateGameStats() {
    levelDisplay.setText(String.format("%s", level));
    scoreDisplay.setText(String.format("%s", userScore));
  }
  private void setToFullScreen() {
    contentView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LOW_PROFILE
        | View.SYSTEM_UI_FLAG_FULLSCREEN
        | View.SYSTEM_UI_FLAG_LAYOUT_STABLE
        | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
        | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
        | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION);
  }
  private static int nextColor() {
    int max = 2;
    int min = 0;
    int retval = 0;
    Random random = new Random();
    retval = random.nextInt((max - min) + 1) + min;
    Log.d(TAG, String.format("retval = %d", retval));
    return retval;
  }
  @Override
  public void popBalloon(Balloon bal, boolean isTouched) {
    balloonsPopped++;
    balloons.remove(bal);
    contentView.removeView(bal);
    audio.playSound();
    if(isTouched) {
      userScore++;
      scoreDisplay.setText(String.format("%d", userScore));
    }
    else {
      pinsUsed++;
      if (pinsUsed <= pinImages.size() ) {
        pinImages.get(pinsUsed -1).setImageResource(R.drawable.pin_broken);
        Toast.makeText(this, "Ouch!",Toast.LENGTH_SHORT).show();
      }
      if(pinsUsed == numberOfPins) {
        gameOver();
      }
    }
    if (balloonsPopped == balloonsPerLevel) {
      finishLevel();
    }
  }
  private void startGame() {
    // reset the scores
    userScore = 0;
    level = 1;
    updateGameStats();
    //reset the pushpin images
    for (ImageView pin: pinImages) {
      pin.setImageResource(R.drawable.pin);
    }
    audio.playMusic();
  }
  private void gameOver() {
    isGameStopped = true;
    Toast.makeText(this, "Game Over", Toast.LENGTH_LONG).show();
    btn.setText("Play game");
    for (Balloon bal : balloons) {
      bal.setPopped(true);
      contentView.removeView(bal);
    }
    balloons.clear();
    audio.pauseMusic();
  }
  class LevelLoop extends Thread {
    private int shortDelay = 500;
    private int longDelay = 1_500;
    private int maxDelay;
    private int minDelay;
    private int delay;
    private int looplevel;
    int balloonsLaunched = 0;
    public LevelLoop(int argLevel) {
      looplevel = argLevel;
    }
    public void run() {
      while (balloonsLaunched <= balloonsPerLevel) {
        balloonsLaunched++;
        Random random = new Random(new Date().getTime());
        final int xPosition = random.nextInt(scrWidth - 200);
        maxDelay = Math.max(shortDelay, (longDelay - ((looplevel -1)) * 500));
        minDelay = maxDelay / 2;
        delay = random.nextInt(minDelay) + minDelay;
        Log.i(TAG, String.format("Thread delay = %d", delay));
        try {
          Thread.sleep(delay);
        }
        catch(InterruptedException e) {
          Log.e(TAG, e.getMessage());
        }
        // need to wrap this on runOnUiThread
        runOnUiThread(new Thread() {
          public void run() {
            launchBalloon(xPosition);
          }
        });
      }
    }
  }
}
Listing 7-46

MainActivity

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.145.107.94