A Simple Game(libgdx demo)

姚麒
2023-12-01

Before diving into the APIs provided by libgdx, let's create a very simple "game" that touches each module a little bit to get a feeling for things. We'll introduce a few concepts without going into to much detail.

We'll look at:

  • Basic file access
  • Clearing the screen
  • Drawing images
  • Using a camera
  • Basic input processing
  • Playing sound effects

Project Setup

Follow the steps in Project Setup. I used the following names:

  • Application name: drop
  • Package name: com.badlogic.drop
  • Game class: Drop

Once imported into Eclipse, you should have 4 projects: drop, drop-android, drop-desktop and drop-html5.

The Game

The game idea is very simple:

  • Catch raindrops with a bucket.
  • Bucket located in the lower part of the screen
  • Raindrops spawn randomly at the top of the screen every second and accelerate downwards.
  • Player can drag the bucket horizontally via the mouse/touch or move it via the left and right cursor keys.
  • The game has no end, think of it as a zen like experience :)

The Assets

We need a few images and sound effects to make the game look somewhat pretty. For the graphics we need to define a target resolution of 800x480 pixels (landscape mode on Android). If the device the game is run on does not have that resolution, we simply scale everything to fit on the screen. Note: for high profile games you might want to consider to have different assets for different screen densities. This is a big topic on its own and won't be covered here.

The raindrop and the bucket should take up 1/10th of the screen vertically, we'll thus have them have a size of 48x48 pixels.

I took the assets from the following sources, and rescaled the images to have a size of 48x48 pixels:

To make the assets available to the game, we have to put them in the Android project's assets folder. I named the 4 files: drop.wav, rain.mp3, droplet.png and bucket.png and put them in the drop-android/assets/ folder. The desktop and HTML5 project have a link to that assets folder, so we only need to store the assets once.

Configuring the Starter Classes

Given our requirements we can now configure our different starter classes. We'll start with the desktop project. Open the Main.java class indrop-desktop/. We want a 800x480 window and set the title to "Drop". The code should look like this:

package com.badlogic.drop;

import com.badlogic.gdx.backends.lwjgl.LwjglApplication;
import com.badlogic.gdx.backends.lwjgl.LwjglApplicationConfiguration;

public class Main {
   public static void main(String[] args) {
      LwjglApplicationConfiguration cfg = new LwjglApplicationConfiguration();
      cfg.title = "Drop";
      cfg.useGL20 = true;
      cfg.width = 800;
      cfg.height = 480;
      new LwjglApplication(new Drop(), cfg);
   }
}

Note: we explicitely request to use OpenGL ES 2.0. This allows us to load images that do not have a power-of-two size, a necessity for images to be used in OpenGL ES 1.0.

Moving on to the Android project, we want the application to be run in landscape mode. For this we need to modify the AndroidManifest.xml in the android project's root directory, which looks like this:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.badlogic.drop"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk android:minSdkVersion="5" android:targetSdkVersion="15" />

    <application
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name" >
        <activity
            android:name=".MainActivity"
            android:label="@string/app_name"
            android:screenOrientation="landscape"
            android:configChanges="keyboard|keyboardHidden|orientation">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

The setup tool already filled in the correct values for us, android:screenOrientation is set to "landscape". If we wanted to run the game in portrait mode we would have set that attribute to "portrait".

We also want to conserve battery and disable the accelerometer and compass. We do this in the MainActivity.java file in the android project, which should look something like this:

package com.badlogic.drop;

import android.os.Bundle;

import com.badlogic.gdx.backends.android.AndroidApplication;
import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration;

public class MainActivity extends AndroidApplication {
   @Override
   public void onCreate(Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
        
      AndroidApplicationConfiguration cfg = new AndroidApplicationConfiguration();
      cfg.useGL20 = true;
      cfg.useAccelerometer = false;
      cfg.useCompass = false;
        
      initialize(new Drop(), cfg);
   }
}

We can not define the resolution of the Activity, as it is set by the Android operating system. As we defined earlier, we'll simply scale the 800x480 target resolution to whatever the resolution of the device is. Note again that we request OpenGL 2.0 so we can use arbitrarily sized images.

Finally we want to make sure the HTML5 project also uses a 800x480 drawing area. For this we modify the GwtLauncher.java file in the html5 project:

package com.badlogic.drop.client;

import com.badlogic.drop.Drop;

public class GwtLauncher extends GwtApplication {
   @Override
   public GwtApplicationConfiguration getConfig () {
      GwtApplicationConfiguration cfg = new GwtApplicationConfiguration(800, 480);
      return cfg;
   }

   @Override
   public ApplicationListener getApplicationListener () {
      return new Drop();
   }
}

Note: we don't need to specify which OpenGL version to use for this platform, as it only supports OpenGL 2.0.

All our starter classes are now correctly configured, let's move on to implementing this fabulous game.

The Code

We want to split up our code into a few sections. For the sake of simplicity we keep everything in the Drop.java file of the core project.

Loading the Assets

Our first task is to load the assets and store references to them. Assets are usually loaded in the ApplicationListener.create() method, so let's do that:

public class Drop implements ApplicationListener {
   Texture dropImage;
   Texture bucketImage;
   Sound dropSound;
   Music rainMusic;
   
   @Override
   public void create() {
      // load the images for the droplet and the bucket, 48x48 pixels each
      dropImage = new Texture(Gdx.files.internal("droplet.png"));
      bucketImage = new Texture(Gdx.files.internal("bucket.png"));
      
      // load the drop sound effect and the rain background "music"
      dropSound = Gdx.audio.newSound(Gdx.files.internal("drop.wav"));
      rainMusic = Gdx.audio.newMusic(Gdx.files.internal("rain.mp3"));
      
      // start the playback of the background music immediately
      rainMusic.setLooping(true);
      rainMusic.play();

      ... more to come ...
   }

   // rest of class omitted for clarity

For each of our assets we have a field in the Drop class so we can later refer to it. The first two lines in the create() method load the images for the raindrop and the bucket. A Texture represents a loaded image that is stored in video ram. One can usually not draw to a Texture. A Textureis loaded by passing a FileHandle to an asset file to its constructor. Such FileHandle instances are obtained through one the of the methods provided by Gdx.files. There are different types of files, we use the "internal" file type here to refer to our assets. Internal files are located in theassets directory of the Android project. The desktop and HTML5 projects reference the same directory through a link in Eclipse.

Next we load the sound effect and the background music. Libgdx differentiates between sound effects, which are stored in memory, and music, which is streamed from wherever it is stored. Music is usually too big to be kept in memory completely, hence the differentiation. As a rule of thumb, you should use a Sound instance if your sample is shorter than 10 seconds, and a Music instance for longer audio pieces.

Loading of a Sound or Music instance is done via Gdx.app.newSound() and Gdx.app.newMusic(). Both of these methods take a FileHandle, just like the Texture constructor.

At the end of the create() method we also tell the Music instance to loop and start playback immediately. If you run the application you'll see a nice pink background and hear the rain fall.

A Camera and a SpriteBatch

Next we want to create a camera and a SpriteBatch. We'll use the former to ensure we can render using our target resolution of 800x480 pixels no matter what the actual screen resolution is. The SpriteBatch is a special class that is used to draw 2D images, like the textures we loaded.

We add two new fields to the class, let's call them camera and batch:

   OrthographicCamera camera;
   SpriteBatch batch;

In the create() method we first create the camera like this:

   camera = new OrthographicCamera();
   camera.setToOrtho(false, 800, 480);

This will make sure the camera always shows us an area of our game world that is 800x480 units wide. Think of it as a virtual window into our world. We currently interpret the units as pixels to make our life a little easier. There's nothing from using other units though, e.g. meters or whatever you have. Cameras are very powerful and allow you to do a lot of things we won't cover in this basic tutorial. Check out the rest of the developer guide for more information.

Next we create the SpriteBatch (we are still in the create() method):

   batch = new SpriteBatch();

We are almost done with creating all the things we need to run this simple game.

Adding the Bucket

The last bits that are missing are representations of our bucket and the raindrop. Let's think about what we need to represent those in code:

  • A bucket/raindrop has an x/y position in the our 800x480 units world.
  • A bucket/raindrop has a width and height, expressed in the units of our world.
  • A bucket/raindrop has a graphical representation, we already have those in form of the Texture instances we loaded.

So, to describe both the bucket and raindrops we need to store their position and size. Libgdx provides a Rectangle class which we can use for this purpose. Let's start by creating a Rectangle that represents our bucket. We add a new field:

   Rectangle bucket;

In the create() method we instantiate the Rectangle and specify it's initial values. We want the bucket to be 20 pixels above the bottom edge of the screen, and centered horizontally.

   bucket = new Rectangle();
   bucket.x = 800 / 2 - 48 / 2;
   bucket.y = 20;
   bucket.width = 48;
   bucket.height = 48;

We center the bucket horizontally and place it 20 pixels above the bottom edge of the screen. Wait, why is bucket.y set to 20, shouldn't it be 480 - 20? By default, all rendering in libgdx (and OpenGL) is performed with the y-axis pointing upwards. The x/y coordinates of the bucket define the bottom left corner of the bucket, the origin for drawing is located in the bottom left corner of the screen. The width and height of the rectangle are set to 48x48, 1/10th of our targe resolutions height.

Note: it is possible to change this setup so the y-axis points down and the origin is in the upper left corner of the screen. OpenGL and the camera class are so flexible that you can have pretty much any kind of viewing angle you want, in 2D and 3D. We'll continue using the above setup.

Rendering the Bucket

Time to render our bucket. The first thing we want to do is to clear the screen with a dark blue color. Simply change the render() method to look like this:

   @Override
   public void render() {
      Gdx.gl.glClearColor(0, 0, 0.2f, 1);
      Gdx.gl.glClear(GL10.GL_COLOR_BUFFER_BIT);

      ... more to come here ...
   }

These two lines are the only things you need to know about OpenGL if you use the high-level classes such as Texture or SpriteBatch. The first call will set the clear color to the color blue. The arguments are the red, green, blue and alpha component of that color, each within the range [0, 1]. The next call instructs OpenGL to actually clear the screen.

Next we need to tell our camera to make sure it is updated. Cameras use a mathematical entity called matrix that is responsible for setting up the coordinate system for rendering. These matrices need to be recomputed everytime we change a property of the camera, like its position. We don't do this in our simple example, but it is generally a good partice to update the camera once per frame:

   camera.update();

Now we can render our bucket:

   batch.setProjectionMatrix(camera.combined);
   batch.begin();
   batch.draw(bucketImage, bucket.x, bucket.y);
   batch.end();

The first line tells the SpriteBatch to use the coordinate system specified by the camera. As stated earlier, this is done with something called a matrix, to be more specific, a projection matrix. The camera.combined field is a such a matrix. From there on the SpriteBatch will render everything in the coordinate system described earlier.

Next we tell the SpriteBatch to start a new batch. Why do we need this and what is a batch? OpenGL hates nothing more than telling it about individual images. It wants to be told about as many images to render as possible at once.

The SpriteBatch class helps making OpenGL happy. It will record all drawing commands in between SpriteBatch.begin() andSpriteBatch.end(). Once we call SpriteBatch.end() it will submit all drawing requests we made at once, speeding up rendering quite a bit. This all might look cumbersome in the beginning, but it is what makes the difference between rendering 500 sprites at 60 frames per second and rendering 100 sprites at 20 frames per second.

Making the Bucket Move (Touch/Mouse)

Time to let the user control the bucket. Earlier we said we'll allow the user to drag the bucket. Let's make things a little bit easier. If the user touches the screen (or presses a mouse button), we want the bucket to center around that position horizontally. Adding the following code to the bottom of the render() method will do this:

   if(Gdx.input.isTouched()) {
      Vector3 touchPos = new Vector3();
      touchPos.set(Gdx.input.getX(), Gdx.input.getY(), 0);
      camera.unproject(touchPos);
      bucket.x = touchPos.x - 48 / 2;
   }

First we ask the input module whether the screen is currently touched (or a mouse button i pressed) by calling Gdx.input.isTouched(). Next we want to tranform the touch/mouse coordinates to our camera's coordinate system. This is necessary because the coordinate system in which touch/mouse coordinates are reported might be different than the coordinate system we use to represent objects in our world.

Gdx.input.getX() and Gdx.input.getY() return the current touch/mouse position (libgdx also supports multi-touch, but that's a topic for a different article). To transform these coordinates to our camera's coordinate system, we need to call the camera.unproject() method, which requests a Vector3, a three dimensional vector. We create such a vector, set the current touch/mouse coordinates and call the method. The vector will now contain the touch/mouse coordinates in the coordinate system our bucket lives in. Finally we change the position of the bucket to be centered around the touch/mouse coordinates.

Note: it is very, very bad to instantiate new object all the time, such as the Vector3 instance. The reason for this is the garbage collector which has to kick in frequently to collect these short-lived objects. On the desktop it's not such a big deal, but on Android the GC can cause pauses up to a few hundred milliseconds which results in stuttering. To solve this issue in this particular case, we can simple make touchPos a field of theDrop class instead of instantiating it all the time.

Note #2: touchPos is a three dimensional vector. You might wonder why that is if we only operate in 2D. OrthographicCamera is actually a 3D camera which takes into account z-coordinates as well. Think of CAD applications, they use 3D orthographic cameras as well. We simply abuse it to draw 2D graphics.

Making the Bucket Move (Keyboard)

On the desktop and in the browser we can also receive keyboard input. Let's make the bucket move when the left or right cursor key is pressed.

We want the bucket to move without acceleration, at two hundred pixels/units per second, either to the left or the right. To implement such time-based movement we need to know the time that passed in between the last and the current rendering frame. Here's how we can do all this:

   if(Gdx.input.isKeyPressed(Keys.LEFT)) bucket.x -= 200 * Gdx.graphics.getDeltaTime();
   if(Gdx.input.isKeyPressed(Keys.RIGHT)) bucket.x += 200 * Gdx.graphics.getDeltaTime();

The method Gdx.input.isKeyPressed() tells us whether a specific key is pressed. The Keys enumeration contains all the keycodes that libgdx supports. The method Gdx.graphics.getDeltaTime() returns the time passed between the last and the current frame in seconds. All we need to do is modify the bucket's x-coordinate by adding/subtracting 100 units times the delta time in seconds.

We also need to make sure our bucket stays within the screen limits:

   if(bucket.x < 0) bucket.x = 0;
   if(bucket.x > 800 - 48) bucket.x = 800 - 48;

Adding the Raindrops

For the raindrops we keep a list of Rectangle instances, each keeping track of the position and size of a raindrop. Let's add that list as a field:

   Array<Rectangle> raindrops;

The Array class is a libgdx utility class to be used instead of standard Java collections like ArrayList. The problem with the later is that they produce garbage in various ways. The Array class tries to minimize garbage as much as possible. Libgdx offers other garbage collector aware collections such as hashmaps or sets as well.

We also need to keep track of the last time we spawned a raindrop, so we add another field:

   long lastDropTime;

We'll store the time in nanoseconds, that's why we use a long.

To facilitate the creation of raindrops we'll write a method called spawnRaindrop() which instantiates a new Rectangle, sets it to a random position at the top edge of the screen and adds it to the raindrops array.

   private void spawnRaindrop() {
      Rectangle raindrop = new Rectangle();
      raindrop.x = MathUtils.random(0, 800-48);
      raindrop.y = 480;
      raindrop.width = 48;
      raindrop.height = 48;
      raindrops.add(raindrop)
      lastDropTime = TimeUtils.nanoTime();
   }

The method should be pretty self-explanatory. The MathUtils class is a libgdx class offering various math related static methods. In this case it will return a random value between zero and 800 - 48. The TimeUtils is another libgdx class that provides some very basic time related static methods. In this case we record the current time in nano seconds based on which we'll later decide whether to spawn a new drop or not.

In the create() method we now instantiate the raindrops array and spawn our first raindrop:

We need to instantiate that array in the create() method:

   raindrops = new Array<Rectangle>();
   spawnRaindrop();

Next we add a few lines to the render() method that will check how much time has passed since we spawned a new raindrop, and creates a new one if necessary:

   if(TimeUtils.nanoTime() - lastDropTime > 1000000000) spawnRaindrop();

We also need to make our raindrops move, let's take the easy route and have them move at a constant speed of 200 pixels/units per second. If the raindrop is beneath the bottom edge of the screen, we remove it from the array.

   Iterator<Rectangle> iter = raindrops.iterator();
   while(iter.hasNext()) {
      Rectangle raindrop = iter.next();
      raindrop.y -= 200 * Gdx.graphics.getDeltaTime();
      if(raindrop.y + 48 < 0) iter.remove();
   }

The raindrops need to be rendered. We'll add that to the SpriteBatch rendering code which looks like this now:

   batch.begin();
   batch.draw(bucketImage, bucket.x, bucket.y);
   for(Rectangle raindrop: raindrops) {
      batch.draw(dropImage, raindrop.x, raindrop.y);
   }
   batch.end();

One final adjustment: if a raindrop hits the bucket, we want to playback our drop sound and remove the raindrop from the array. We simply add the following lines to the raindrop update loop:

      if(raindrop.overlaps(bucket)) {
         dropSound.play();
         iter.remove();
      }

The Rectangle.overlaps() method checks if this rectangel overlaps with another rectangle. In our case, we tell the drop sound effect to play itself back and remove the raindrop from the array.

Cleaning Up

A user can close the application at any time. For this simple example there's nothing that needs to be done. However, it is in general a good idea to help out the operating system a little and clean up the mess we created.

Any libgdx class that implements the Disposable interface and thus has a dispose() method needs to be cleaned up manually once it is no longer used. In our example that's true for the textures, the sound and music and the SpriteBatch. Being good citizens, we implement the{ApplicationListener#dispose() method as follows:

   @Override
   public void dispose() {
      dropImage.dispose();
      bucketImage.dispose();
      dropSound.dispose();
      rainMusic.dispose();
      batch.dispose();
   }

Once you dispose of a resource, you should not access it in any way.

Disposables are usually native resources which are not handled by the Java garbage collector. This is the reason why we need to manually dispose of them. Libgdx provides various ways to help with asset managment. Read the rest of the development guide to discover them.

Handling Pausing/Resuming

Android has the notation of pausing and resuming your application every time the user gets a phone call or presses the home button. Libgdx will do many things automatically for you in that case, e.g. reload images that might have gotten lost (OpenGL context loss, a terrible topic on its own), pause and resume music streams and so on.

In our game there's no real need to handle pausing/resuming. As soon as the user comes back to the application, the game continues where it left. Usually one would implement a pause screen and ask the user to touch the screen to continue. This is left as an exercise for the reader. Check out the ApplicationListener.pause() and ApplicationListener.resume() methods.

The Full Source

Here's the tiny source for our simple game:

package com.badlogic.drop;

import java.util.Iterator;

import com.badlogic.gdx.ApplicationListener;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input.Keys;
import com.badlogic.gdx.audio.Music;
import com.badlogic.gdx.audio.Sound;
import com.badlogic.gdx.graphics.GL10;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.math.Vector3;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.TimeUtils;

public class Drop implements ApplicationListener {
   Texture dropImage;
   Texture bucketImage;
   Sound dropSound;
   Music rainMusic;
   SpriteBatch batch;
   OrthographicCamera camera;
   Rectangle bucket;
   Array<Rectangle> raindrops;
   long lastDropTime;
   
   @Override
   public void create() {
      // load the images for the droplet and the bucket, 48x48 pixels each
      dropImage = new Texture(Gdx.files.internal("droplet.png"));
      bucketImage = new Texture(Gdx.files.internal("bucket.png"));
      
      // load the drop sound effect and the rain background "music"
      dropSound = Gdx.audio.newSound(Gdx.files.internal("drop.wav"));
      rainMusic = Gdx.audio.newMusic(Gdx.files.internal("rain.mp3"));
      
      // start the playback of the background music immediately
      rainMusic.setLooping(true);
      rainMusic.play();
      
      // create the camera and the SpriteBatch
      camera = new OrthographicCamera();
      camera.setToOrtho(false, 800, 480);
      batch = new SpriteBatch();
      
      // create a Rectangle to logically represent the bucket
      bucket = new Rectangle();
      bucket.x = 800 / 2 - 48 / 2; // center the bucket horizontally
      bucket.y = 20; // bottom left corner of the bucket is 20 pixels above the bottom screen edge
      bucket.width = 48;
      bucket.height = 48;
      
      // create the raindrops array and spawn the first raindrop
      raindrops = new Array<Rectangle>();
      spawnRaindrop();
   }
   
   private void spawnRaindrop() {
      Rectangle raindrop = new Rectangle();
      raindrop.x = MathUtils.random(0, 800-48);
      raindrop.y = 480;
      raindrop.width = 48;
      raindrop.height = 48;
      raindrops.add(raindrop);
      lastDropTime = TimeUtils.nanoTime();
   }

   @Override
   public void render() {
      // clear the screen with a dark blue color. The
      // arguments to glClearColor are the red, green
      // blue and alpha component in the range [0,1]
      // of the color to be used to clear the screen.
      Gdx.gl.glClearColor(0, 0, 0.2f, 1);
      Gdx.gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
      
      // tell the camera to update its matrices.
      camera.update();
      
      // tell the SpriteBatch to render in the
      // coordinate system specified by the camera.
      batch.setProjectionMatrix(camera.combined);
      
      // begin a new batch and draw the bucket and
      // all drops
      batch.begin();
      batch.draw(bucketImage, bucket.x, bucket.y);
      for(Rectangle raindrop: raindrops) {
         batch.draw(dropImage, raindrop.x, raindrop.y);
      }
      batch.end();
      
      // process user input
      if(Gdx.input.isTouched()) {
         Vector3 touchPos = new Vector3();
         touchPos.set(Gdx.input.getX(), Gdx.input.getY(), 0);
         camera.unproject(touchPos);
         bucket.x = touchPos.x - 48 / 2;
      }
      if(Gdx.input.isKeyPressed(Keys.LEFT)) bucket.x -= 200 * Gdx.graphics.getDeltaTime();
      if(Gdx.input.isKeyPressed(Keys.RIGHT)) bucket.x += 200 * Gdx.graphics.getDeltaTime();
      
      // make sure the bucket stays within the screen bounds
      if(bucket.x < 0) bucket.x = 0;
      if(bucket.x > 800 - 48) bucket.x = 800 - 48;
      
      // check if we need to create a new raindrop
      if(TimeUtils.nanoTime() - lastDropTime > 1000000000) spawnRaindrop();
      
      // move the raindrops, remove any that are beneath the bottom edge of
      // the screen or that hit the bucket. In the later case we play back
      // a sound effect as well.
      Iterator<Rectangle> iter = raindrops.iterator();
      while(iter.hasNext()) {
         Rectangle raindrop = iter.next();
         raindrop.y -= 200 * Gdx.graphics.getDeltaTime();
         if(raindrop.y + 48 < 0) iter.remove();
         if(raindrop.overlaps(bucket)) {
            dropSound.play();
            iter.remove();
         }
      }
   }
   
   @Override
   public void dispose() {
      // dispose of all the native resources
      dropImage.dispose();
      bucketImage.dispose();
      dropSound.dispose();
      rainMusic.dispose();
      batch.dispose();
   }

   @Override
   public void resize(int width, int height) {
   }

   @Override
   public void pause() {
   }

   @Override
   public void resume() {
   }
}

Where to go from here

This was a very basic example of how to use libgdx to create a minimalistic game. Some things can be improved, like using the Pool class to recycle all the Rectangles we have the garbage collector clean up each time we delete a raindrop. OpenGL is also not to fond if we hand it to many different images in a batch. In our case it is OK, as we only had two images. Usually one would put all those images into a single Texture, also known as a TextureAtlas.

I'd highly recommend reading the rest of the developer guide and checking out the demos and tests in the SVN repository. Happy coding.

Source Code

You can checkout the source code and assets for this article athttp://code.google.com/p/libgdx/source/browse/#svn%2Ftrunk%2Fdemos%2Fdev-guide via an SVN client of your choice.

 类似资料:

相关阅读

相关文章

相关问答