LibGDX Zombie Bird Tutorial (Flappy Bird Clone/Remake)
Day 11 - Supporting iOS/Android + SplashScreen, Menus and Tweening
Welcome to Day 11! Now that gameplay is out of the way, we are going to focus on the UI by implementing more screens and adding transitions using the Tween Engine by Aurelien Ribon. In Day 2, I asked you to download the Universal Tween Engine through the libGDX setup. I forgot to click the checkbox next to the Universal Tween Engine after downloading, so I have to use the libGDX setup to update my project.
Adding Tween Engine Library to our libGDX projects
To check if you have correctly setup the Tween Engine, check your core project in Eclipse. You should have these two highlighted files inside the lib folder:
If your project DOES have these files, you are in good shape. If you DO NOT have these files, we have to add them through the libGDX setup gui. To do that, take the following steps (or skip and download the day_11_starting.zip below these instructions):
1. Find out where your core project is located. This can be found by right clicking (Control + click on Mac) on the project, and opening the Properties. Take note of the file Location.
1. Find out where your core project is located. This can be found by right clicking (Control + click on Mac) on the project, and opening the Properties. Take note of the file Location.
2. Open up the gdx-setup-ui jar, like we did in Day 2. If you need to download that again, click here.
3. Specify the core project path as shown below.
4. Make sure you select "Universal Tween Engine" by clicking the checkbox.
3. Specify the core project path as shown below.
4. Make sure you select "Universal Tween Engine" by clicking the checkbox.
5. Click the 'Open the update screen' to the right.
6. You should see the screen below. Click launch!
6. You should see the screen below. Click launch!
That's it! Now our Eclipse projects will be able to make use of the tween-engine-api.
Updated Source Code
Here's the latest source code. It contains all the progress we made until Day 10, and the additional fixes that I asked you to make after Day 10, including the steps above.

day_11_starting.zip | |
File Size: | 13381 kb |
File Type: | zip |
Setting up Android Project
We will now setup our Android Project, so that you can start testing your game on Android devices. Open the ZombieGame-android project.
1. Changing the icon:
To change the icon, open the res > drawable-hdpi folder. Replace the ic_launcher.png with your own image, such as this one:
1. Changing the icon:
To change the icon, open the res > drawable-hdpi folder. Replace the ic_launcher.png with your own image, such as this one:

ic_launcher.png | |
File Size: | 3 kb |
File Type: | png |
2. Changing Screen Orientation
The default libGDX screen orientation is landscape. We will be using portrait mode.
Open the AndroidManifest.xml and change this line:
android:screenOrientation="landscape"
to:
android:screenOrientation="portrait"
The default libGDX screen orientation is landscape. We will be using portrait mode.
Open the AndroidManifest.xml and change this line:
android:screenOrientation="landscape"
to:
android:screenOrientation="portrait"
3. Lastly, change the application name by going to the values > string.xml file, and change the name as shown below:
Now our Android project is ready! You can start deploying it to a development device or a virtual device by running the project as an Android application
Setting up the iOS Project
To test your libGDX game on iOS, you must have an Intel-based Mac. If you meet those requirements, we must setup RoboVM on our machine. To do that, follow this link: http://www.robovm.org/docs#start and download JDK 7, Xcode and download RoboVM for Eclipse.
To install RoboVM for Eclipse, you must open Eclipse, go to Help > Install New Software and enter the following link: http://download.robovm.org/eclipse/
Once that is downloaded, you must restart Eclipse.
Make sure you open up Xcode once before proceeding.
Next, make sure that Eclipse is running using Java 7 by opening up Eclipse's preferences and making sure that you have selected your JDK 7's path:
To install RoboVM for Eclipse, you must open Eclipse, go to Help > Install New Software and enter the following link: http://download.robovm.org/eclipse/
Once that is downloaded, you must restart Eclipse.
Make sure you open up Xcode once before proceeding.
Next, make sure that Eclipse is running using Java 7 by opening up Eclipse's preferences and making sure that you have selected your JDK 7's path:
Then, restart Eclipse once more.
You can change your application icons in the data folder for various screen sizes according to Apple's Human Interface Guidelines: https://developer.apple.com/library/ios/documentation/userexperience/conceptual/mobilehig/
When you start an iOS app, a default image is shown while the app is loading in the background to give the illusion that the app has loaded quickly. You can also set these in the data folder.
You can change your application icons in the data folder for various screen sizes according to Apple's Human Interface Guidelines: https://developer.apple.com/library/ios/documentation/userexperience/conceptual/mobilehig/
When you start an iOS app, a default image is shown while the app is loading in the background to give the illusion that the app has loaded quickly. You can also set these in the data folder.
Next, we must change the screen orientation by opening Info.plist.xml. Locate the following keys:
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
And change it to this:
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
Finally change the app name by opening robovm.properties and making the change shown below in bold:
#Fri May 31 13:01:40 CEST 2013
app.version=1.0
app.id=com.kilobolt.ZombieBird
app.main.kilobolt.ZombieBird.RobovmLauncher
app.executable=ZBGame
app.build=1
app.name=Zombie Bird
You can now control click on your robovm project and run it as an iPhone Simulator Application. The building takes a very long time, so wait patiently:
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
And change it to this:
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
Finally change the app name by opening robovm.properties and making the change shown below in bold:
#Fri May 31 13:01:40 CEST 2013
app.version=1.0
app.id=com.kilobolt.ZombieBird
app.main.kilobolt.ZombieBird.RobovmLauncher
app.executable=ZBGame
app.build=1
app.name=Zombie Bird
You can now control click on your robovm project and run it as an iPhone Simulator Application. The building takes a very long time, so wait patiently:
Once you run the application, it should start up on your iOS simulator as shown below! If you see a libGDX image, that's the default image that is shown when the game is loading, so that is normal!
Updated Textures
Begin by downloading these two texture files into your assets/data folder:.
![]()
|
![]()
|
SimpleButton Object
Create a new package called com.kilobolt.ui. Inside, create a SimpleButton class. We will use this for basic UI. Look through the code. It should be mostly self-explanatory.
package com.kilobolt.ui; import com.badlogic.gdx.graphics.g2d.SpriteBatch; import com.badlogic.gdx.graphics.g2d.TextureRegion; import com.badlogic.gdx.math.Rectangle; public class SimpleButton { private float x, y, width, height; private TextureRegion buttonUp; private TextureRegion buttonDown; private Rectangle bounds; private boolean isPressed = false; public SimpleButton(float x, float y, float width, float height, TextureRegion buttonUp, TextureRegion buttonDown) { this.x = x; this.y = y; this.width = width; this.height = height; this.buttonUp = buttonUp; this.buttonDown = buttonDown; bounds = new Rectangle(x, y, width, height); } public boolean isClicked(int screenX, int screenY) { return bounds.contains(screenX, screenY); } public void draw(SpriteBatch batcher) { if (isPressed) { batcher.draw(buttonDown, x, y, width, height); } else { batcher.draw(buttonUp, x, y, width, height); } } public boolean isTouchDown(int screenX, int screenY) { if (bounds.contains(screenX, screenY)) { isPressed = true; return true; } return false; } public boolean isTouchUp(int screenX, int screenY) { // It only counts as a touchUp if the button is in a pressed state. if (bounds.contains(screenX, screenY) && isPressed) { isPressed = false; return true; } // Whenever a finger is released, we will cancel any presses. isPressed = false; return false; } }
Update the AssetLoader
Since we've added a new texture and updated the old one, we need to update our AssetManager as shown:
package com.kilobolt.ZBHelpers; import com.badlogic.gdx.Gdx; import com.badlogic.gdx.Preferences; import com.badlogic.gdx.audio.Sound; import com.badlogic.gdx.graphics.Texture; import com.badlogic.gdx.graphics.Texture.TextureFilter; import com.badlogic.gdx.graphics.g2d.Animation; import com.badlogic.gdx.graphics.g2d.BitmapFont; import com.badlogic.gdx.graphics.g2d.TextureRegion; public class AssetLoader { public static Texture texture, logoTexture; public static TextureRegion logo, zbLogo, bg, grass, bird, birdDown, birdUp, skullUp, skullDown, bar, playButtonUp, playButtonDown; public static Animation birdAnimation; public static Sound dead, flap, coin; public static BitmapFont font, shadow; private static Preferences prefs; public static void load() { logoTexture = new Texture(Gdx.files.internal("data/logo.png")); logoTexture.setFilter(TextureFilter.Linear, TextureFilter.Linear); logo = new TextureRegion(logoTexture, 0, 0, 512, 114); texture = new Texture(Gdx.files.internal("data/texture.png")); texture.setFilter(TextureFilter.Nearest, TextureFilter.Nearest); playButtonUp = new TextureRegion(texture, 0, 83, 29, 16); playButtonDown = new TextureRegion(texture, 29, 83, 29, 16); playButtonUp.flip(false, true); playButtonDown.flip(false, true); zbLogo = new TextureRegion(texture, 0, 55, 135, 24); zbLogo.flip(false, true); bg = new TextureRegion(texture, 0, 0, 136, 43); bg.flip(false, true); grass = new TextureRegion(texture, 0, 43, 143, 11); grass.flip(false, true); birdDown = new TextureRegion(texture, 136, 0, 17, 12); birdDown.flip(false, true); bird = new TextureRegion(texture, 153, 0, 17, 12); bird.flip(false, true); birdUp = new TextureRegion(texture, 170, 0, 17, 12); birdUp.flip(false, true); TextureRegion[] birds = { birdDown, bird, birdUp }; birdAnimation = new Animation(0.06f, birds); birdAnimation.setPlayMode(Animation.LOOP_PINGPONG); skullUp = new TextureRegion(texture, 192, 0, 24, 14); // Create by flipping existing skullUp skullDown = new TextureRegion(skullUp); skullDown.flip(false, true); bar = new TextureRegion(texture, 136, 16, 22, 3); bar.flip(false, true); dead = Gdx.audio.newSound(Gdx.files.internal("data/dead.wav")); flap = Gdx.audio.newSound(Gdx.files.internal("data/flap.wav")); coin = Gdx.audio.newSound(Gdx.files.internal("data/coin.wav")); font = new BitmapFont(Gdx.files.internal("data/text.fnt")); font.setScale(.25f, -.25f); shadow = new BitmapFont(Gdx.files.internal("data/shadow.fnt")); shadow.setScale(.25f, -.25f); // Create (or retrieve existing) preferences file prefs = Gdx.app.getPreferences("ZombieBird"); if (!prefs.contains("highScore")) { prefs.putInteger("highScore", 0); } } public static void setHighScore(int val) { prefs.putInteger("highScore", val); prefs.flush(); } public static int getHighScore() { return prefs.getInteger("highScore"); } public static void dispose() { // We must dispose of the texture when we are finished. texture.dispose(); // Dispose sounds dead.dispose(); flap.dispose(); coin.dispose(); font.dispose(); shadow.dispose(); } }
The TweenEngine
We have added the Tween Engine library to our project. Let's talk about what it does.
The Tween Engine allows you to mathematically interpolate between a starting value and a final value.
For example, let's say that I have some float called x, with a value of zero. I want to exponentially change this value to a final value of 1 (gradually increase from 0 to 1 at increasing speed). Let's add that I want all of this done in exactly 2.8 seconds.
This is what the Tween Engine enables us to do.
The general implementation of a Tween Engine works like this:
You have a class called Point, for example, which as the following instance variables:
float x = 0;
float y = 0;
To use the Tween Engine, to mathematically interpolate these values to x = 1 and y = 5, you need to create a TweenAccessor called PointAccessor.
This class will have two of your own methods. The first method is a getter. It retrieves the values of all the variables that you want to modify from the Point object, and stores it inside an array.
Behind the scenes, the Tween Engine will receive these values and modify them. It will then return the modified values to you in the second method, where you can then send these new values to your Point object.
Let's see an example:
Create a new package called: com.kilobolt.TweenAccessors and create a SpriteAccessor class:
The Tween Engine allows you to mathematically interpolate between a starting value and a final value.
For example, let's say that I have some float called x, with a value of zero. I want to exponentially change this value to a final value of 1 (gradually increase from 0 to 1 at increasing speed). Let's add that I want all of this done in exactly 2.8 seconds.
This is what the Tween Engine enables us to do.
The general implementation of a Tween Engine works like this:
You have a class called Point, for example, which as the following instance variables:
float x = 0;
float y = 0;
To use the Tween Engine, to mathematically interpolate these values to x = 1 and y = 5, you need to create a TweenAccessor called PointAccessor.
This class will have two of your own methods. The first method is a getter. It retrieves the values of all the variables that you want to modify from the Point object, and stores it inside an array.
Behind the scenes, the Tween Engine will receive these values and modify them. It will then return the modified values to you in the second method, where you can then send these new values to your Point object.
Let's see an example:
Create a new package called: com.kilobolt.TweenAccessors and create a SpriteAccessor class:
package com.kilobolt.TweenAccessors; import aurelienribon.tweenengine.TweenAccessor; import com.badlogic.gdx.graphics.g2d.Sprite; public class SpriteAccessor implements TweenAccessor<Sprite> { public static final int ALPHA = 1; @Override public int getValues(Sprite target, int tweenType, float[] returnValues) { switch (tweenType) { case ALPHA: returnValues[0] = target.getColor().a; return 1; default: return 0; } } @Override public void setValues(Sprite target, int tweenType, float[] newValues) { switch (tweenType) { case ALPHA: target.setColor(1, 1, 1, newValues[0]); break; } } }
The class above is an implementation of TweenAccessor, specifically for the Sprite class. As I've previously mentioned, all classes that you want to modify using the TweenEngine need their own Accessor.
The above TweenAccessor only modifies one variable (the alpha, or opacity). If we wanted to modify more variables in different ways, we would create more constants to denote other types of tweening that our Accessor is capable of (such as rotation).
All TweenAccessors need to have two methods: getValues and setValues, which each target the specific class that you are tweening, in this case the Sprite class.
Your role when creating a TweenAccessor is simple. 1. Retrieve the values that you want modified from the Object you are changing, and place them into an array. Let the Tween Engine do its work. 2. Then, you receive the modified values and send them back to the Object you are changing.
Let's see how the methods above fulfill these roles:
1. In the getValues method, you must retrieve all the values that you want to change from the Sprite object, and store them into the array of floats called returnValues. In this case we are only changing one value, so we can store it into the first index of returnValues: returnValues[0].
Behind the scenes, this value is modified (brought closer to the final value that you have specified elsewhere).
2. After that, these updated values are available to you in the setValues method (in the same indices, or positions). Whatever you placed into returnValues[0] will now be available as newValues[0]. You next simply send this value back to the Sprite object.
These methods are called automatically. You only have to provide the initial and final values. These will hopefully make more sense when you see this Accessor in action.
Create a SplashScreen class inside com.kilobolt.Screens, as shown below.
The above TweenAccessor only modifies one variable (the alpha, or opacity). If we wanted to modify more variables in different ways, we would create more constants to denote other types of tweening that our Accessor is capable of (such as rotation).
All TweenAccessors need to have two methods: getValues and setValues, which each target the specific class that you are tweening, in this case the Sprite class.
Your role when creating a TweenAccessor is simple. 1. Retrieve the values that you want modified from the Object you are changing, and place them into an array. Let the Tween Engine do its work. 2. Then, you receive the modified values and send them back to the Object you are changing.
Let's see how the methods above fulfill these roles:
1. In the getValues method, you must retrieve all the values that you want to change from the Sprite object, and store them into the array of floats called returnValues. In this case we are only changing one value, so we can store it into the first index of returnValues: returnValues[0].
Behind the scenes, this value is modified (brought closer to the final value that you have specified elsewhere).
2. After that, these updated values are available to you in the setValues method (in the same indices, or positions). Whatever you placed into returnValues[0] will now be available as newValues[0]. You next simply send this value back to the Sprite object.
These methods are called automatically. You only have to provide the initial and final values. These will hopefully make more sense when you see this Accessor in action.
Create a SplashScreen class inside com.kilobolt.Screens, as shown below.
package com.kilobolt.Screens; import aurelienribon.tweenengine.BaseTween; import aurelienribon.tweenengine.Tween; import aurelienribon.tweenengine.TweenCallback; import aurelienribon.tweenengine.TweenEquations; import aurelienribon.tweenengine.TweenManager; import com.badlogic.gdx.Gdx; import com.badlogic.gdx.Screen; import com.badlogic.gdx.graphics.GL10; import com.badlogic.gdx.graphics.g2d.Sprite; import com.badlogic.gdx.graphics.g2d.SpriteBatch; import com.kilobolt.TweenAccessors.SpriteAccessor; import com.kilobolt.ZBHelpers.AssetLoader; import com.kilobolt.ZombieBird.ZBGame; public class SplashScreen implements Screen { private TweenManager manager; private SpriteBatch batcher; private Sprite sprite; private ZBGame game; public SplashScreen(ZBGame game) { this.game = game; } @Override public void show() { sprite = new Sprite(AssetLoader.logo); sprite.setColor(1, 1, 1, 0); float width = Gdx.graphics.getWidth(); float height = Gdx.graphics.getHeight(); float desiredWidth = width * .7f; float scale = desiredWidth / sprite.getWidth(); sprite.setSize(sprite.getWidth() * scale, sprite.getHeight() * scale); sprite.setPosition((width / 2) - (sprite.getWidth() / 2), (height / 2) - (sprite.getHeight() / 2)); setupTween(); batcher = new SpriteBatch(); } private void setupTween() { Tween.registerAccessor(Sprite.class, new SpriteAccessor()); manager = new TweenManager(); TweenCallback cb = new TweenCallback() { @Override public void onEvent(int type, BaseTween<?> source) { game.setScreen(new GameScreen()); } }; Tween.to(sprite, SpriteAccessor.ALPHA, .8f).target(1) .ease(TweenEquations.easeInOutQuad).repeatYoyo(1, .4f) .setCallback(cb).setCallbackTriggers(TweenCallback.COMPLETE) .start(manager); } @Override public void render(float delta) { manager.update(delta); Gdx.gl.glClearColor(1, 1, 1, 1); Gdx.gl.glClear(GL10.GL_COLOR_BUFFER_BIT); batcher.begin(); sprite.draw(batcher); batcher.end(); } @Override public void resize(int width, int height) { } @Override public void hide() { // TODO Auto-generated method stub } @Override public void pause() { // TODO Auto-generated method stub } @Override public void resume() { // TODO Auto-generated method stub } @Override public void dispose() { // TODO Auto-generated method stub } }
Modifying ZBGame
Before we discuss what SplashScreen does, we want SplashScreen, not GameScreen to open when we run the game. So let's open our ZBGame class, and make this change:
package com.kilobolt.ZombieBird; import com.badlogic.gdx.Game; import com.kilobolt.Screens.SplashScreen; import com.kilobolt.ZBHelpers.AssetLoader; public class ZBGame extends Game { @Override public void create() { AssetLoader.load(); setScreen(new SplashScreen(this)); } @Override public void dispose() { super.dispose(); AssetLoader.dispose(); } }
Now go back to the SplashScreen class.
Let's focus specifically on the setupTween method, as the other code is mostly self-explanatory at htis point.
privateTween.registerAccessor(Sprite.class, new SpriteAccessor());
1. This line registers a new Accessor. We are basically saying, "I want to be able to modify Sprite objects using the Tween Engine. Here's the Accessor I made for it that follows your specifications (that we must have a getValues method and a setValues method)."
manager = new TweenManager();
2. The Tween Engine requires a TweenManager to work, which is updated in our render method with the delta value. This manager will then handle the interpolation for us using our SpriteAccessor.
TweenCallback cb = new TweenCallback() {
@Override
public void onEvent(int type, BaseTween<?> source) {
game.setScreen(new GameScreen());
}
};
3. We can create something called a TweenCallback, which is an object whose methods are called when Tweening is complete. In this case, we create a new TweenCallback called cb, whose onEvent method (which will be called when our Tweening is finished), will send us to the GameScreen.
Before we move on to the most important stuff, let's see what we are trying to accomplish. We are trying to take our logo sprite, start it at 0 opacity, increase it to 1 (100%) and bring it back down to zero.
Let's look at this big chunk of code in parts:
Tween.to(sprite, SpriteAccessor.ALPHA, .8f).target(1).ease(TweenEquations.easeInOutQuad).repeatYoyo(1, .4f) .setCallback(cb).setCallbackTriggers(TweenCallback.COMPLETE) .start(manager);
Tween.to(sprite, SpriteAccessor.ALPHA, .8f).target(1)
- This says: We are going to tween the sprite object using the SpriteAccessor's ALPHA tweenType. We want this to take .8 seconds. We want you to modify the starting alpha value (this is specified in the SpriteAccessor class) to the desired target value of 1.
.ease(TweenEquations.easeInOutQuad).repeatYoyo(1, .4f)
- We want this to use a quadratic interpolation (you will see what this does), and repeat once as a Yoyo (with .4 seconds between the repetition). This accomplishes the desired effect of bringing our opacity back down to zero once it hits one.
.setCallback(cb).setCallbackTriggers(TweenCallback.COMPLETE)
This then says, use the callback that we have created above called cb, and notify it when tweening is complete.
.start(manager);
Finally, this specifies which manager will be doing the work.
Now have a look at the render method and see what the manager is doing, and then run the code.
This is probably very confusing! It's hard to understand until you have experimented with it, so before moving on, I encourage you to do some of your own experimentation.
Let's focus specifically on the setupTween method, as the other code is mostly self-explanatory at htis point.
privateTween.registerAccessor(Sprite.class, new SpriteAccessor());
1. This line registers a new Accessor. We are basically saying, "I want to be able to modify Sprite objects using the Tween Engine. Here's the Accessor I made for it that follows your specifications (that we must have a getValues method and a setValues method)."
manager = new TweenManager();
2. The Tween Engine requires a TweenManager to work, which is updated in our render method with the delta value. This manager will then handle the interpolation for us using our SpriteAccessor.
TweenCallback cb = new TweenCallback() {
@Override
public void onEvent(int type, BaseTween<?> source) {
game.setScreen(new GameScreen());
}
};
3. We can create something called a TweenCallback, which is an object whose methods are called when Tweening is complete. In this case, we create a new TweenCallback called cb, whose onEvent method (which will be called when our Tweening is finished), will send us to the GameScreen.
Before we move on to the most important stuff, let's see what we are trying to accomplish. We are trying to take our logo sprite, start it at 0 opacity, increase it to 1 (100%) and bring it back down to zero.
Let's look at this big chunk of code in parts:
Tween.to(sprite, SpriteAccessor.ALPHA, .8f).target(1).ease(TweenEquations.easeInOutQuad).repeatYoyo(1, .4f) .setCallback(cb).setCallbackTriggers(TweenCallback.COMPLETE) .start(manager);
Tween.to(sprite, SpriteAccessor.ALPHA, .8f).target(1)
- This says: We are going to tween the sprite object using the SpriteAccessor's ALPHA tweenType. We want this to take .8 seconds. We want you to modify the starting alpha value (this is specified in the SpriteAccessor class) to the desired target value of 1.
.ease(TweenEquations.easeInOutQuad).repeatYoyo(1, .4f)
- We want this to use a quadratic interpolation (you will see what this does), and repeat once as a Yoyo (with .4 seconds between the repetition). This accomplishes the desired effect of bringing our opacity back down to zero once it hits one.
.setCallback(cb).setCallbackTriggers(TweenCallback.COMPLETE)
This then says, use the callback that we have created above called cb, and notify it when tweening is complete.
.start(manager);
Finally, this specifies which manager will be doing the work.
Now have a look at the render method and see what the manager is doing, and then run the code.
This is probably very confusing! It's hard to understand until you have experimented with it, so before moving on, I encourage you to do some of your own experimentation.
More TweenAccessors
Now that you know how TweenAccessors work, create two new classes for your com.kilobolt.TweenAccessors package.
The first class is a simple Value class, which will just be a wrapper class for one float. We create this because only Objects can be tweened (primitives cannot). So, to tween a float, we must create a class for it.
The first class is a simple Value class, which will just be a wrapper class for one float. We create this because only Objects can be tweened (primitives cannot). So, to tween a float, we must create a class for it.
package com.kilobolt.TweenAccessors; public class Value { private float val = 1; public float getValue() { return val; } public void setValue(float newVal) { val = newVal; } }
The ValueAccessor class will help us tween the val variable in the Value class above:
package com.kilobolt.TweenAccessors; import aurelienribon.tweenengine.TweenAccessor; public class ValueAccessor implements TweenAccessor<Value> { @Override public int getValues(Value target, int tweenType, float[] returnValues) { returnValues[0] = target.getValue(); return 1; } @Override public void setValues(Value target, int tweenType, float[] newValues) { target.setValue(newValues[0]); } }
The ValueAccessor will be used whenever we need to tween a float. For example if we want to flash a screen using a white rectangle with changing opacity, we will create a new Value object and tween it using our ValueAccessor. In fact, we are going to do this to smoothly transition into our GameScreen from the SplashScreen.
We are going to make several changes to our GameScreen, which consists of the GameScreen class and its helpers: InputHandler, GameWorld and GameRenderer classes.
To keep Day 11 short and to prevent this from becoming 3 separate lessons, I will only discuss the major changes made to the code. Most of the changes will be easy to grasp, and after a little bit of experimentation, you will understand their purposes. If you get stuck, do post in the comments!
Start by making changes to the InputHandler class:
We are going to make several changes to our GameScreen, which consists of the GameScreen class and its helpers: InputHandler, GameWorld and GameRenderer classes.
To keep Day 11 short and to prevent this from becoming 3 separate lessons, I will only discuss the major changes made to the code. Most of the changes will be easy to grasp, and after a little bit of experimentation, you will understand their purposes. If you get stuck, do post in the comments!
Start by making changes to the InputHandler class:
package com.kilobolt.ZBHelpers; import java.util.ArrayList; import java.util.List; import com.badlogic.gdx.Input.Keys; import com.badlogic.gdx.InputProcessor; import com.kilobolt.GameObjects.Bird; import com.kilobolt.GameWorld.GameWorld; import com.kilobolt.ui.SimpleButton; public class InputHandler implements InputProcessor { private Bird myBird; private GameWorld myWorld; private List<SimpleButton> menuButtons; private SimpleButton playButton; private float scaleFactorX; private float scaleFactorY; public InputHandler(GameWorld myWorld, float scaleFactorX, float scaleFactorY) { this.myWorld = myWorld; myBird = myWorld.getBird(); int midPointY = myWorld.getMidPointY(); this.scaleFactorX = scaleFactorX; this.scaleFactorY = scaleFactorY; menuButtons = new ArrayList<SimpleButton>(); playButton = new SimpleButton( 136 / 2 - (AssetLoader.playButtonUp.getRegionWidth() / 2), midPointY + 50, 29, 16, AssetLoader.playButtonUp, AssetLoader.playButtonDown); menuButtons.add(playButton); } @Override public boolean touchDown(int screenX, int screenY, int pointer, int button) { screenX = scaleX(screenX); screenY = scaleY(screenY); System.out.println(screenX + " " + screenY); if (myWorld.isMenu()) { playButton.isTouchDown(screenX, screenY); } else if (myWorld.isReady()) { myWorld.start(); } myBird.onClick(); if (myWorld.isGameOver() || myWorld.isHighScore()) { myWorld.restart(); } return true; } @Override public boolean touchUp(int screenX, int screenY, int pointer, int button) { screenX = scaleX(screenX); screenY = scaleY(screenY); if (myWorld.isMenu()) { if (playButton.isTouchUp(screenX, screenY)) { myWorld.ready(); return true; } } return false; } @Override public boolean keyDown(int keycode) { // Can now use Space Bar to play the game if (keycode == Keys.SPACE) { if (myWorld.isMenu()) { myWorld.ready(); } else if (myWorld.isReady()) { myWorld.start(); } myBird.onClick(); if (myWorld.isGameOver() || myWorld.isHighScore()) { myWorld.restart(); } } return false; } @Override public boolean keyUp(int keycode) { return false; } @Override public boolean keyTyped(char character) { return false; } @Override public boolean touchDragged(int screenX, int screenY, int pointer) { return false; } @Override public boolean mouseMoved(int screenX, int screenY) { return false; } @Override public boolean scrolled(int amount) { return false; } private int scaleX(int screenX) { return (int) (screenX / scaleFactorX); } private int scaleY(int screenY) { return (int) (screenY / scaleFactorY); } public List<SimpleButton> getMenuButtons() { return menuButtons; } }
The big change to the InputHandler is that we will be generating buttons here. This is not the best way to go about doing this, but as buttons are heavily reliant on input, I have created them here.
The role of the InputHandler is now to create the Buttons and to handle interactions with them, as shown above.
I also created some methods so that we can scale the touches (which are currently dependent on screen size) to our screen size independent game world width and height. Now, touches at x and y will be reported using our GameWorld's coordinates.
I also enabled the use of the Spacebar for those who want to use the keyboard.
To handle the changes to the constructor, we must now go update our GameScreen class. The changes here are minor and I will let you review them. :) (Notice the change to the renderer.render() call).
The role of the InputHandler is now to create the Buttons and to handle interactions with them, as shown above.
I also created some methods so that we can scale the touches (which are currently dependent on screen size) to our screen size independent game world width and height. Now, touches at x and y will be reported using our GameWorld's coordinates.
I also enabled the use of the Spacebar for those who want to use the keyboard.
To handle the changes to the constructor, we must now go update our GameScreen class. The changes here are minor and I will let you review them. :) (Notice the change to the renderer.render() call).
package com.kilobolt.Screens; import com.badlogic.gdx.Gdx; import com.badlogic.gdx.Screen; import com.kilobolt.GameWorld.GameRenderer; import com.kilobolt.GameWorld.GameWorld; import com.kilobolt.ZBHelpers.InputHandler; public class GameScreen implements Screen { private GameWorld world; private GameRenderer renderer; private float runTime; // This is the constructor, not the class declaration public GameScreen() { float screenWidth = Gdx.graphics.getWidth(); float screenHeight = Gdx.graphics.getHeight(); float gameWidth = 136; float gameHeight = screenHeight / (screenWidth / gameWidth); int midPointY = (int) (gameHeight / 2); world = new GameWorld(midPointY); Gdx.input.setInputProcessor(new InputHandler(world, screenWidth / gameWidth, screenHeight / gameHeight)); renderer = new GameRenderer(world, (int) gameHeight, midPointY); } @Override public void render(float delta) { runTime += delta; world.update(delta); renderer.render(delta, runTime); } @Override public void resize(int width, int height) { } @Override public void show() { } @Override public void hide() { } @Override public void pause() { } @Override public void resume() { } @Override public void dispose() { } }
The GameWorld went through some minor changes (dealing with GameState and a few new methods):
package com.kilobolt.GameWorld; import com.badlogic.gdx.math.Intersector; import com.badlogic.gdx.math.Rectangle; import com.kilobolt.GameObjects.Bird; import com.kilobolt.GameObjects.ScrollHandler; import com.kilobolt.ZBHelpers.AssetLoader; public class GameWorld { private Bird bird; private ScrollHandler scroller; private Rectangle ground; private int score = 0; private float runTime = 0; private int midPointY; private GameState currentState; public enum GameState { MENU, READY, RUNNING, GAMEOVER, HIGHSCORE } public GameWorld(int midPointY) { currentState = GameState.MENU; this.midPointY = midPointY; bird = new Bird(33, midPointY - 5, 17, 12); // The grass should start 66 pixels below the midPointY scroller = new ScrollHandler(this, midPointY + 66); ground = new Rectangle(0, midPointY + 66, 137, 11); } public void update(float delta) { runTime += delta; switch (currentState) { case READY: case MENU: updateReady(delta); break; case RUNNING: updateRunning(delta); break; default: break; } } private void updateReady(float delta) { bird.updateReady(runTime); scroller.updateReady(delta); } public void updateRunning(float delta) { if (delta > .15f) { delta = .15f; } bird.update(delta); scroller.update(delta); if (scroller.collides(bird) && bird.isAlive()) { scroller.stop(); bird.die(); AssetLoader.dead.play(); } if (Intersector.overlaps(bird.getBoundingCircle(), ground)) { scroller.stop(); bird.die(); bird.decelerate(); currentState = GameState.GAMEOVER; if (score > AssetLoader.getHighScore()) { AssetLoader.setHighScore(score); currentState = GameState.HIGHSCORE; } } } public Bird getBird() { return bird; } public int getMidPointY() { return midPointY; } public ScrollHandler getScroller() { return scroller; } public int getScore() { return score; } public void addScore(int increment) { score += increment; } public void start() { currentState = GameState.RUNNING; } public void ready() { currentState = GameState.READY; } public void restart() { currentState = GameState.READY; score = 0; bird.onRestart(midPointY - 5); scroller.onRestart(); currentState = GameState.READY; } public boolean isReady() { return currentState == GameState.READY; } public boolean isGameOver() { return currentState == GameState.GAMEOVER; } public boolean isHighScore() { return currentState == GameState.HIGHSCORE; } public boolean isMenu() { return currentState == GameState.MENU; } public boolean isRunning() { return currentState == GameState.RUNNING; } }
This was accompanied by minor changes in the Bird class and the ScrollHandler class.
Changes to Bird class
Changes to Bird class
package com.kilobolt.GameObjects; import com.badlogic.gdx.math.Circle; import com.badlogic.gdx.math.Vector2; import com.kilobolt.ZBHelpers.AssetLoader; public class Bird { private Vector2 position; private Vector2 velocity; private Vector2 acceleration; private float rotation; private int width; private float height; private float originalY; private boolean isAlive; private Circle boundingCircle; public Bird(float x, float y, int width, int height) { this.width = width; this.height = height; this.originalY = y; position = new Vector2(x, y); velocity = new Vector2(0, 0); acceleration = new Vector2(0, 460); boundingCircle = new Circle(); isAlive = true; } public void update(float delta) { velocity.add(acceleration.cpy().scl(delta)); if (velocity.y > 200) { velocity.y = 200; } // CEILING CHECK if (position.y < -13) { position.y = -13; velocity.y = 0; } position.add(velocity.cpy().scl(delta)); // Set the circle's center to be (9, 6) with respect to the bird. // Set the circle's radius to be 6.5f; boundingCircle.set(position.x + 9, position.y + 6, 6.5f); // Rotate counterclockwise if (velocity.y < 0) { rotation -= 600 * delta; if (rotation < -20) { rotation = -20; } } // Rotate clockwise if (isFalling() || !isAlive) { rotation += 480 * delta; if (rotation > 90) { rotation = 90; } } } public void updateReady(float runTime) { position.y = 2 * (float) Math.sin(7 * runTime) + originalY; } public boolean isFalling() { return velocity.y > 110; } public boolean shouldntFlap() { return velocity.y > 70 || !isAlive; } public void onClick() { if (isAlive) { AssetLoader.flap.play(); velocity.y = -140; } } public void die() { isAlive = false; velocity.y = 0; } public void decelerate() { acceleration.y = 0; } public void onRestart(int y) { rotation = 0; position.y = y; velocity.x = 0; velocity.y = 0; acceleration.x = 0; acceleration.y = 460; isAlive = true; } public float getX() { return position.x; } public float getY() { return position.y; } public float getWidth() { return width; } public float getHeight() { return height; } public float getRotation() { return rotation; } public Circle getBoundingCircle() { return boundingCircle; } public boolean isAlive() { return isAlive; } }
Changes to ScrollHandler class
package com.kilobolt.GameObjects; import com.kilobolt.GameWorld.GameWorld; import com.kilobolt.ZBHelpers.AssetLoader; public class ScrollHandler { private Grass frontGrass, backGrass; private Pipe pipe1, pipe2, pipe3; public static final int SCROLL_SPEED = -59; public static final int PIPE_GAP = 49; private GameWorld gameWorld; public ScrollHandler(GameWorld gameWorld, float yPos) { this.gameWorld = gameWorld; frontGrass = new Grass(0, yPos, 143, 11, SCROLL_SPEED); backGrass = new Grass(frontGrass.getTailX(), yPos, 143, 11, SCROLL_SPEED); pipe1 = new Pipe(210, 0, 22, 60, SCROLL_SPEED, yPos); pipe2 = new Pipe(pipe1.getTailX() + PIPE_GAP, 0, 22, 70, SCROLL_SPEED, yPos); pipe3 = new Pipe(pipe2.getTailX() + PIPE_GAP, 0, 22, 60, SCROLL_SPEED, yPos); } public void updateReady(float delta) { frontGrass.update(delta); backGrass.update(delta); // Same with grass if (frontGrass.isScrolledLeft()) { frontGrass.reset(backGrass.getTailX()); } else if (backGrass.isScrolledLeft()) { backGrass.reset(frontGrass.getTailX()); } } public void update(float delta) { // Update our objects frontGrass.update(delta); backGrass.update(delta); pipe1.update(delta); pipe2.update(delta); pipe3.update(delta); // Check if any of the pipes are scrolled left, // and reset accordingly if (pipe1.isScrolledLeft()) { pipe1.reset(pipe3.getTailX() + PIPE_GAP); } else if (pipe2.isScrolledLeft()) { pipe2.reset(pipe1.getTailX() + PIPE_GAP); } else if (pipe3.isScrolledLeft()) { pipe3.reset(pipe2.getTailX() + PIPE_GAP); } // Same with grass if (frontGrass.isScrolledLeft()) { frontGrass.reset(backGrass.getTailX()); } else if (backGrass.isScrolledLeft()) { backGrass.reset(frontGrass.getTailX()); } } public void stop() { frontGrass.stop(); backGrass.stop(); pipe1.stop(); pipe2.stop(); pipe3.stop(); } public boolean collides(Bird bird) { if (!pipe1.isScored() && pipe1.getX() + (pipe1.getWidth() / 2) < bird.getX() + bird.getWidth()) { addScore(1); pipe1.setScored(true); AssetLoader.coin.play(); } else if (!pipe2.isScored() && pipe2.getX() + (pipe2.getWidth() / 2) < bird.getX() + bird.getWidth()) { addScore(1); pipe2.setScored(true); AssetLoader.coin.play(); } else if (!pipe3.isScored() && pipe3.getX() + (pipe3.getWidth() / 2) < bird.getX() + bird.getWidth()) { addScore(1); pipe3.setScored(true); AssetLoader.coin.play(); } return (pipe1.collides(bird) || pipe2.collides(bird) || pipe3 .collides(bird)); } private void addScore(int increment) { gameWorld.addScore(increment); } public Grass getFrontGrass() { return frontGrass; } public Grass getBackGrass() { return backGrass; } public Pipe getPipe1() { return pipe1; } public Pipe getPipe2() { return pipe2; } public Pipe getPipe3() { return pipe3; } public void onRestart() { frontGrass.onRestart(0, SCROLL_SPEED); backGrass.onRestart(frontGrass.getTailX(), SCROLL_SPEED); pipe1.onRestart(210, SCROLL_SPEED); pipe2.onRestart(pipe1.getTailX() + PIPE_GAP, SCROLL_SPEED); pipe3.onRestart(pipe2.getTailX() + PIPE_GAP, SCROLL_SPEED); } }
The biggest refactoring occurred in the GameRenderer class. However, the changes are minor.
package com.kilobolt.GameWorld; import java.util.List; import aurelienribon.tweenengine.Tween; import aurelienribon.tweenengine.TweenEquations; import aurelienribon.tweenengine.TweenManager; import com.badlogic.gdx.Gdx; import com.badlogic.gdx.graphics.GL10; import com.badlogic.gdx.graphics.OrthographicCamera; import com.badlogic.gdx.graphics.g2d.Animation; import com.badlogic.gdx.graphics.g2d.SpriteBatch; import com.badlogic.gdx.graphics.g2d.TextureRegion; import com.badlogic.gdx.graphics.glutils.ShapeRenderer; import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType; import com.kilobolt.GameObjects.Bird; import com.kilobolt.GameObjects.Grass; import com.kilobolt.GameObjects.Pipe; import com.kilobolt.GameObjects.ScrollHandler; import com.kilobolt.TweenAccessors.Value; import com.kilobolt.TweenAccessors.ValueAccessor; import com.kilobolt.ZBHelpers.AssetLoader; import com.kilobolt.ZBHelpers.InputHandler; import com.kilobolt.ui.SimpleButton; public class GameRenderer { private GameWorld myWorld; private OrthographicCamera cam; private ShapeRenderer shapeRenderer; private SpriteBatch batcher; private int midPointY; // Game Objects private Bird bird; private ScrollHandler scroller; private Grass frontGrass, backGrass; private Pipe pipe1, pipe2, pipe3; // Game Assets private TextureRegion bg, grass, birdMid, skullUp, skullDown, bar; private Animation birdAnimation; // Tween stuff private TweenManager manager; private Value alpha = new Value(); // Buttons private List<SimpleButton> menuButtons; public GameRenderer(GameWorld world, int gameHeight, int midPointY) { myWorld = world; this.midPointY = midPointY; this.menuButtons = ((InputHandler) Gdx.input.getInputProcessor()) .getMenuButtons(); cam = new OrthographicCamera(); cam.setToOrtho(true, 136, gameHeight); batcher = new SpriteBatch(); batcher.setProjectionMatrix(cam.combined); shapeRenderer = new ShapeRenderer(); shapeRenderer.setProjectionMatrix(cam.combined); initGameObjects(); initAssets(); setupTweens(); } private void setupTweens() { Tween.registerAccessor(Value.class, new ValueAccessor()); manager = new TweenManager(); Tween.to(alpha, -1, .5f).target(0).ease(TweenEquations.easeOutQuad) .start(manager); } private void initGameObjects() { bird = myWorld.getBird(); scroller = myWorld.getScroller(); frontGrass = scroller.getFrontGrass(); backGrass = scroller.getBackGrass(); pipe1 = scroller.getPipe1(); pipe2 = scroller.getPipe2(); pipe3 = scroller.getPipe3(); } private void initAssets() { bg = AssetLoader.bg; grass = AssetLoader.grass; birdAnimation = AssetLoader.birdAnimation; birdMid = AssetLoader.bird; skullUp = AssetLoader.skullUp; skullDown = AssetLoader.skullDown; bar = AssetLoader.bar; } private void drawGrass() { // Draw the grass batcher.draw(grass, frontGrass.getX(), frontGrass.getY(), frontGrass.getWidth(), frontGrass.getHeight()); batcher.draw(grass, backGrass.getX(), backGrass.getY(), backGrass.getWidth(), backGrass.getHeight()); } private void drawSkulls() { batcher.draw(skullUp, pipe1.getX() - 1, pipe1.getY() + pipe1.getHeight() - 14, 24, 14); batcher.draw(skullDown, pipe1.getX() - 1, pipe1.getY() + pipe1.getHeight() + 45, 24, 14); batcher.draw(skullUp, pipe2.getX() - 1, pipe2.getY() + pipe2.getHeight() - 14, 24, 14); batcher.draw(skullDown, pipe2.getX() - 1, pipe2.getY() + pipe2.getHeight() + 45, 24, 14); batcher.draw(skullUp, pipe3.getX() - 1, pipe3.getY() + pipe3.getHeight() - 14, 24, 14); batcher.draw(skullDown, pipe3.getX() - 1, pipe3.getY() + pipe3.getHeight() + 45, 24, 14); } private void drawPipes() { batcher.draw(bar, pipe1.getX(), pipe1.getY(), pipe1.getWidth(), pipe1.getHeight()); batcher.draw(bar, pipe1.getX(), pipe1.getY() + pipe1.getHeight() + 45, pipe1.getWidth(), midPointY + 66 - (pipe1.getHeight() + 45)); batcher.draw(bar, pipe2.getX(), pipe2.getY(), pipe2.getWidth(), pipe2.getHeight()); batcher.draw(bar, pipe2.getX(), pipe2.getY() + pipe2.getHeight() + 45, pipe2.getWidth(), midPointY + 66 - (pipe2.getHeight() + 45)); batcher.draw(bar, pipe3.getX(), pipe3.getY(), pipe3.getWidth(), pipe3.getHeight()); batcher.draw(bar, pipe3.getX(), pipe3.getY() + pipe3.getHeight() + 45, pipe3.getWidth(), midPointY + 66 - (pipe3.getHeight() + 45)); } private void drawBirdCentered(float runTime) { batcher.draw(birdAnimation.getKeyFrame(runTime), 59, bird.getY() - 15, bird.getWidth() / 2.0f, bird.getHeight() / 2.0f, bird.getWidth(), bird.getHeight(), 1, 1, bird.getRotation()); } private void drawBird(float runTime) { if (bird.shouldntFlap()) { batcher.draw(birdMid, bird.getX(), bird.getY(), bird.getWidth() / 2.0f, bird.getHeight() / 2.0f, bird.getWidth(), bird.getHeight(), 1, 1, bird.getRotation()); } else { batcher.draw(birdAnimation.getKeyFrame(runTime), bird.getX(), bird.getY(), bird.getWidth() / 2.0f, bird.getHeight() / 2.0f, bird.getWidth(), bird.getHeight(), 1, 1, bird.getRotation()); } } private void drawMenuUI() { batcher.draw(AssetLoader.zbLogo, 136 / 2 - 56, midPointY - 50, AssetLoader.zbLogo.getRegionWidth() / 1.2f, AssetLoader.zbLogo.getRegionHeight() / 1.2f); for (SimpleButton button : menuButtons) { button.draw(batcher); } } private void drawScore() { int length = ("" + myWorld.getScore()).length(); AssetLoader.shadow.draw(batcher, "" + myWorld.getScore(), 68 - (3 * length), midPointY - 82); AssetLoader.font.draw(batcher, "" + myWorld.getScore(), 68 - (3 * length), midPointY - 83); } public void render(float delta, float runTime) { Gdx.gl.glClearColor(0, 0, 0, 1); Gdx.gl.glClear(GL10.GL_COLOR_BUFFER_BIT); shapeRenderer.begin(ShapeType.Filled); // Draw Background color shapeRenderer.setColor(55 / 255.0f, 80 / 255.0f, 100 / 255.0f, 1); shapeRenderer.rect(0, 0, 136, midPointY + 66); // Draw Grass shapeRenderer.setColor(111 / 255.0f, 186 / 255.0f, 45 / 255.0f, 1); shapeRenderer.rect(0, midPointY + 66, 136, 11); // Draw Dirt shapeRenderer.setColor(147 / 255.0f, 80 / 255.0f, 27 / 255.0f, 1); shapeRenderer.rect(0, midPointY + 77, 136, 52); shapeRenderer.end(); batcher.begin(); batcher.disableBlending(); batcher.draw(bg, 0, midPointY + 23, 136, 43); drawGrass(); drawPipes(); batcher.enableBlending(); drawSkulls(); if (myWorld.isRunning()) { drawBird(runTime); drawScore(); } else if (myWorld.isReady()) { drawBird(runTime); drawScore(); } else if (myWorld.isMenu()) { drawBirdCentered(runTime); drawMenuUI(); } else if (myWorld.isGameOver()) { drawBird(runTime); drawScore(); } else if (myWorld.isHighScore()) { drawBird(runTime); drawScore(); } batcher.end(); drawTransition(delta); } private void drawTransition(float delta) { if (alpha.getValue() > 0) { manager.update(delta); Gdx.gl.glEnable(GL10.GL_BLEND); Gdx.gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA); shapeRenderer.begin(ShapeType.Filled); shapeRenderer.setColor(1, 1, 1, alpha.getValue()); shapeRenderer.rect(0, 0, 136, 300); shapeRenderer.end(); Gdx.gl.glDisable(GL10.GL_BLEND); } } }
Look through the code above and let me know if you have any questions!
There's still work left to be done in terms of UI. Here is how we will proceed. I will post the sample code for the finished game as Day 12, which will have all the UI completed. This will be the end of Unit 1, which aims to mirror the Flappy Bird experience.
Then we will begin Unit 2, integrating the requested features such as leaderboards and Facebook integration.
Thank you for reading, and like us on Facebook or follow us on Twitter to stay updated!
There's still work left to be done in terms of UI. Here is how we will proceed. I will post the sample code for the finished game as Day 12, which will have all the UI completed. This will be the end of Unit 1, which aims to mirror the Flappy Bird experience.
Then we will begin Unit 2, integrating the requested features such as leaderboards and Facebook integration.
Thank you for reading, and like us on Facebook or follow us on Twitter to stay updated!
Source Code for the day.
If you didn't feel like writing that code yourself, download the completed code here:
Download, extract and import into eclipse:
Download, extract and import into eclipse:

day_11.zip | |
File Size: | 10565 kb |
File Type: | zip |
Like us on Facebook to be informed as soon as the next lesson is available.
|
|
comments powered by Disqus