Subscribe to GameFromScratch on YouTube Support GameFromScratch on Patreon
3. February 2015

 

In this next part in the ongoing LibGDX Video tutorial series, we take our final look at the Scene2D library.  Specifically we look at using Scene2D to provide a UI, including buttons, windows, layout containers and more.  We also look at how to mix “normal” and Scene2D games together. 

 

The video is available in full HD here.  The source code from the examples is below the video:

 

Example 1

package com.gamefromscratch;

import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.scenes.scene2d.InputEvent;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.ui.Dialog;
import com.badlogic.gdx.scenes.scene2d.ui.Skin;
import com.badlogic.gdx.scenes.scene2d.ui.TextButton;
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener;
import com.badlogic.gdx.utils.Timer;
import com.badlogic.gdx.utils.viewport.ScreenViewport;

public class Scene2DUiDemo1 extends ApplicationAdapter {
   private Skin skin;
   private Stage stage;
   
   @Override
   public void create () {
      skin = new Skin(Gdx.files.internal("uiskin.json"));
      stage = new Stage(new ScreenViewport());

      final TextButton button = new TextButton("Click Me",skin,"default");
      button.setWidth(200);
      button.setHeight(50);

      final Dialog dialog = new Dialog("Click Message",skin);

      button.addListener(new ClickListener(){
         @Override
         public void clicked(InputEvent event, float x, float y) {
            dialog.show(stage);
            Timer.schedule(new Timer.Task() {
               @Override
               public void run() {
                  dialog.hide();
               }
            }, 10);
         }
      });
      stage.addActor(button);

      Gdx.input.setInputProcessor(stage);
   }

   @Override
   public void render () {
      Gdx.gl.glClearColor(0, 0, 0, 1);
      Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
      stage.act(Gdx.graphics.getDeltaTime());
      stage.draw();
   }


}

Example 2

package com.gamefromscratch;

import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.scenes.scene2d.InputEvent;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.ui.*;
import com.badlogic.gdx.scenes.scene2d.utils.Align;
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener;
import com.badlogic.gdx.utils.Timer;
import com.badlogic.gdx.utils.viewport.ScreenViewport;

public class Scene2DUiDemo1 extends ApplicationAdapter {
   private Skin skin;
   private Stage stage;

   private Table table;
   private TextButton startButton;
   private TextButton quitButton;
   
   @Override
   public void create () {
      skin = new Skin(Gdx.files.internal("uiskin.json"));
      stage = new Stage(new ScreenViewport());
      table = new Table();
      table.setWidth(stage.getWidth());
      table.align(Align.center | Align.top);

      table.setPosition(0,Gdx.graphics.getHeight());
      startButton = new TextButton("New Game",skin);
      quitButton = new TextButton("Quit Game",skin);

      table.padTop(30);

      table.add(startButton).padBottom(30);

      table.row();
      table.add(quitButton);

      stage.addActor(table);
      Gdx.input.setInputProcessor(stage);
   }

   @Override
   public void render () {
      Gdx.gl.glClearColor(0, 0, 0, 1);
      Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
      stage.act(Gdx.graphics.getDeltaTime());
      stage.draw();
   }


}

 

Example 3

package com.gamefromscratch;

import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.InputMultiplexer;
import com.badlogic.gdx.InputProcessor;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Sprite;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.scenes.scene2d.InputEvent;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.actions.RepeatAction;
import com.badlogic.gdx.scenes.scene2d.ui.*;
import com.badlogic.gdx.scenes.scene2d.utils.Align;
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener;
import com.badlogic.gdx.utils.Timer;
import com.badlogic.gdx.utils.viewport.ScreenViewport;

public class Scene2DUiDemo1 extends ApplicationAdapter implements InputProcessor {
   private Skin skin;
   private Stage stage;

   private Table table;
   private TextButton startButton;
   private TextButton quitButton;

   private SpriteBatch batch;
   private Sprite sprite;
   
   @Override
   public void create () {
      skin = new Skin(Gdx.files.internal("uiskin.json"));
      stage = new Stage(new ScreenViewport());
      table = new Table();
      table.setWidth(stage.getWidth());
      table.align(Align.center | Align.top);

      table.setPosition(0,Gdx.graphics.getHeight());
      startButton = new TextButton("New Game",skin);
      quitButton = new TextButton("Quit Game",skin);

      startButton.addListener(new ClickListener() {
         @Override
         public void clicked(InputEvent event, float x, float y) {
            Gdx.app.log("Clicked button","Yep, you did");
            event.stop();
         }
      });

      table.padTop(30);

      table.add(startButton).padBottom(30);

      table.row();
      table.add(quitButton);

      stage.addActor(table);



      batch = new SpriteBatch();
      sprite = new Sprite(new Texture(Gdx.files.internal("badlogic.jpg")));
      sprite.setSize(Gdx.graphics.getWidth(),Gdx.graphics.getHeight());

      Timer.schedule(new Timer.Task() {
         @Override
         public void run() {
            sprite.setFlip(false,!sprite.isFlipY());
         }
      },10,10,10000);


      // ORDER IS IMPORTANT!
      InputMultiplexer im = new InputMultiplexer(stage,this);
      Gdx.input.setInputProcessor(im);
   }

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

      batch.begin();
      sprite.draw(batch);
      batch.end();

      stage.act(Gdx.graphics.getDeltaTime());
      stage.draw();
   }


   @Override
   public boolean keyDown(int keycode) {
      return false;
   }

   @Override
   public boolean keyUp(int keycode) {
      return false;
   }

   @Override
   public boolean keyTyped(char character) {
      return false;
   }

   @Override
   public boolean touchDown(int screenX, int screenY, int pointer, int button) {
      sprite.setFlip(!sprite.isFlipX(),sprite.isFlipY());
      return true;
   }

   @Override
   public boolean touchUp(int screenX, int screenY, int pointer, int button) {
      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;
   }
}

 

uiskin.json

// @formatter:off
{
  com.badlogic.gdx.graphics.g2d.BitmapFont: { default-font: { file: Razer.fnt } },
  com.badlogic.gdx.scenes.scene2d.ui.TextButton$TextButtonStyle: {
   default: { down: default-round-down, up: default-round, font: default-font },
  },
  com.badlogic.gdx.scenes.scene2d.ui.Window$WindowStyle: {
    default:  {
      titleFont: default-font
     }
   }
}

Programming


20. January 2015

 

In this third part of multiple part video tutorial looking at using Scene2D we look at creating Groups for organizing Actors, as well as hit testing, making it possible to select actors available in your scene.  All of the code is included below.  For an HD version of the video click here.

 

 

Groups

package com.gamefromscratch.group;

import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input;
import com.badlogic.gdx.InputProcessor;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.math.Vector;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.scenes.scene2d.Actor;
import com.badlogic.gdx.scenes.scene2d.Group;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.scenes.scene2d.ui.Image;
import com.badlogic.gdx.utils.viewport.ScreenViewport;

public class GroupDemo extends ApplicationAdapter implements InputProcessor {
   Stage stage;

   @Override
   public void create () {
      stage = new Stage(new ScreenViewport());
      Group group =  new Group();

      Image tableImg = new Image(new Texture(Gdx.files.internal("table.png")));
      Image aceImg = new Image(new Texture(Gdx.files.internal("ace.png")));
      Image kingImg = new Image(new Texture(Gdx.files.internal("king.png")));

      tableImg.setName("table");
      aceImg.setName("ace");
      kingImg.setName("king");

      group.addActor(tableImg);
      group.addActor(kingImg);
      group.addActor(aceImg);

      stage.addActor(group);

      kingImg.setPosition(300,150);
      aceImg.setPosition(400,150);

      Gdx.input.setInputProcessor(this);
   }

   @Override
   public void render () {
      Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
      stage.act(Gdx.graphics.getDeltaTime());
      stage.draw();
   }

   @Override
   public boolean keyDown(int keycode) {
      Group group = (Group)stage.getActors().first();
      Image ace = (Image)group.findActor("ace");

      if(keycode == Input.Keys.RIGHT)
         if(Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT))
            ace.setRotation(ace.getRotation() + 1f);
         else
            group.setRotation(group.getRotation() + 1f);

      if(keycode == Input.Keys.LEFT)
         if(Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT))
            ace.setRotation(ace.getRotation() -1f);
         else
            group.setRotation(group.getRotation() - 1f);

      if(keycode == Input.Keys.UP)
         group.setColor(group.getColor().r,group.getColor().g,
               group.getColor().b,group.getColor().a + 0.1f );

      if(keycode == Input.Keys.DOWN)
         group.setColor(group.getColor().r,group.getColor().g,
               group.getColor().b,group.getColor().a - 0.1f );

      if(keycode == Input.Keys.NUM_1)
         ace.setZIndex(ace.getZIndex() -1);
      if(keycode == Input.Keys.NUM_2)
         ace.setZIndex(ace.getZIndex() +1);

      return true;
   }

   @Override
   public boolean keyUp(int keycode) {
      return false;
   }

   @Override
   public boolean keyTyped(char character) {
      return false;
   }

   @Override
   public boolean touchDown(int screenX, int screenY, int pointer, int button) {
      Vector2 coord = stage.screenToStageCoordinates(new Vector2((float)screenX,(float)screenY));
      Actor hitActor = stage.hit(coord.x,coord.y,false);

      if(hitActor != null)
         Gdx.app.log("HIT",hitActor.getName());

      return true;
   }

   @Override
   public boolean touchUp(int screenX, int screenY, int pointer, int button) {
      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;
   }
}

Programming


6. January 2015

 

In this second part of multiple part video tutorial looking at using Scene2D we look at creating Actors and Actions.  Actors are the entities that compose your game, while Actions are the things that make those Actors do… stuff.  The code for this example is included below the video.  The video on YouTube is available in high definition.

 

 

Actor Demo

package com.gamefromscratch;

import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.utils.viewport.ScreenViewport;

public class ActorDemo extends ApplicationAdapter {

   Stage stage;

   @Override
   public void create () {
      ScreenViewport viewport = new ScreenViewport();
      stage = new Stage(viewport);
      Gdx.input.setInputProcessor(stage);

      MyActor actor = new MyActor();
      stage.addActor(actor);
      stage.setKeyboardFocus(actor);
   }

   @Override
   public void render () {
      Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
      stage.act(Gdx.graphics.getDeltaTime());
      stage.draw();
   }
}
 
package com.gamefromscratch;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.graphics.g2d.Sprite;
import com.badlogic.gdx.scenes.scene2d.Actor;
import com.badlogic.gdx.scenes.scene2d.InputEvent;
import com.badlogic.gdx.scenes.scene2d.InputListener;
import com.badlogic.gdx.scenes.scene2d.Touchable;
import com.badlogic.gdx.scenes.scene2d.actions.MoveByAction;

public class MyActor extends Actor {
    Sprite sprite = new Sprite(new Texture(Gdx.files.internal("badlogic.jpg")));

    public MyActor(){
        setBounds(sprite.getX(),sprite.getY(),sprite.getWidth(),sprite.getHeight());
        setTouchable(Touchable.enabled);

        addListener(new InputListener(){
            @Override
            public boolean keyDown(InputEvent event, int keycode) {
                if(keycode == Input.Keys.RIGHT){
                    MoveByAction mba = new MoveByAction();
                    mba.setAmount(100f,0f);
                    mba.setDuration(5f);

                    MyActor.this.addAction(mba);
                }
                return true;
            }
        });
    }

    @Override
    protected void positionChanged() {
        sprite.setPosition(getX(),getY());
        super.positionChanged();
    }

    @Override
    public void draw(Batch batch, float parentAlpha) {
        sprite.draw(batch);
    }

    @Override
    public void act(float delta) {
        super.act(delta);
    }
}

 

Action Demo

package com.gamefromscratch;

import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.utils.viewport.ScreenViewport;

public class ActionDemo extends ApplicationAdapter {

   Stage stage;

   @Override
   public void create () {
      stage = new Stage(new ScreenViewport());
      stage.addActor(new MyActor(new Texture(Gdx.files.internal("badlogic.jpg"))));
      Gdx.input.setInputProcessor(stage);
      stage.setKeyboardFocus(stage.getActors().first());
   }

   @Override
   public void render () {
      Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
      stage.act(Gdx.graphics.getDeltaTime());
      stage.draw();
   }
}
 
package com.gamefromscratch;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.scenes.scene2d.InputEvent;
import com.badlogic.gdx.scenes.scene2d.InputListener;
import com.badlogic.gdx.scenes.scene2d.actions.*;
import com.badlogic.gdx.scenes.scene2d.ui.Image;
import com.badlogic.gdx.scenes.scene2d.utils.TextureRegionDrawable;

import static com.badlogic.gdx.scenes.scene2d.actions.Actions.*;
import static com.badlogic.gdx.scenes.scene2d.actions.Actions.addAction;


public class MyActor extends Image {

    @Override
    public void draw(Batch batch, float parentAlpha) {
        batch.setColor(this.getColor());

        ((TextureRegionDrawable)getDrawable()).draw(batch, getX(),getY(),
                getOriginX(),getOriginY(),
                getWidth(),getHeight(),
                getScaleX(),getScaleY(),
                getRotation());
    }

    public MyActor(Texture texture){
        super(texture);

        setBounds(getX(),getY(),getWidth(),getHeight());

        addListener(new InputListener(){
            @Override
            public boolean keyDown(InputEvent event, int keycode) {
                switch(keycode) {
                    case Input.Keys.NUM_1:
                        MoveToAction moveToAction = new MoveToAction();
                        moveToAction.setPosition(200f,200f);
                        moveToAction.setDuration(5f);
                        MyActor.this.addAction(moveToAction);
                        break;

                    case Input.Keys.NUM_2:
                        MoveByAction moveByAction = new MoveByAction();
                        moveByAction.setAmount(-200f,0f);
                        moveByAction.setDuration(3f);
                        MyActor.this.addAction(moveByAction);
                        break;

                    case Input.Keys.NUM_3:
                        ColorAction colorAction = new ColorAction();
                        colorAction.setEndColor(Color.PURPLE);
                        colorAction.setDuration(5f);
                        MyActor.this.addAction(colorAction);
                        break;

                    case Input.Keys.NUM_4:
                        MoveToAction mta = new MoveToAction();
                        mta.setPosition(Gdx.graphics.getWidth() - 200f,Gdx.graphics.getHeight()-200f);
                        mta.setDuration(3f);

                        ScaleByAction sba = new ScaleByAction();
                        sba.setAmount(2f);
                        sba.setDuration(3f);

                        RotateToAction rta = new RotateToAction();
                        rta.setRotation(90f);
                        rta.setDuration(3f);

                        ParallelAction pa = new ParallelAction(mta,sba,rta);
                        MyActor.this.addAction(pa);
                        break;

                    case Input.Keys.NUM_5:
                        MoveToAction mta2 = new MoveToAction();
                        mta2.setPosition(Gdx.graphics.getWidth() - 200f,Gdx.graphics.getHeight()-200f);
                        mta2.setDuration(3f);

                        ScaleByAction sba2 = new ScaleByAction();
                        sba2.setAmount(2f);
                        sba2.setDuration(3f);

                        RotateToAction rta2 = new RotateToAction();
                        rta2.setRotation(90f);
                        rta2.setDuration(3f);

                        SequenceAction sa = new SequenceAction(mta2,sba2,rta2);
                        MyActor.this.addAction(sa);
                        break;

                    case Input.Keys.NUM_6:
                        RunnableAction ra = new RunnableAction();
                        ra.setRunnable(new Runnable(){
                            @Override
                            public void run() {
                                MyActor.this.setPosition(0f,0f);
                                MyActor.this.setRotation(0f);
                                MyActor.this.setScale(1f);
                            }
                        });
                        MyActor.this.addAction(ra);
                        break;

                    case Input.Keys.SPACE:
                        addAction(parallel(
                            moveTo(200f, 200f, 3f),
                            scaleTo(2f, 3f),
                            rotateTo(90f, 3f)
                        ));
                        break;
                }
                return true;
            }
        });
    }
}

Programming


30. December 2014

 

In this first of multiple part video tutorial we start looking at using Scene2D in LibGDX.  Scene2D is a library built over top of LibGDX providing a scene graph and UI/widget layer.  None of that make any sense?  Then watch the video! :)  Speaking of which, it is available in 1080p on YouTube.

 

 

The code from the example:

package com.gamefromscratch;

import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.scenes.scene2d.Actor;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.utils.viewport.ScreenViewport;

public class Scene2DDemo1 extends ApplicationAdapter {

   class MyActor extends Actor {
      Texture texture = new Texture(Gdx.files.internal("badlogic.jpg"));

      @Override
      public void draw(Batch batch, float parentAlpha) {
         batch.draw(texture,0,0);
      }
   }
   Stage stage;
   @Override
   public void create () {
      stage = new Stage(new ScreenViewport());
      MyActor actor = new MyActor();
      stage.addActor(actor);
      Gdx.input.setInputProcessor(stage);
   }

   @Override
   public void render () {
      Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
      stage.draw();
   }
}

Programming


19. December 2014

 

In this tutorial we look at the process of creating and using a Spritesheet in LibGDX.  This involves creating a series of sprites, putting them together with TexturePacker, then using a TextureAtlas and TextureRegion to display them with our Sprite.  We also quickly look at TexturePacker ( different product ) for those that prefer a UI.  Sample code and links to included assets below the video.

 

Once again, you can view the video in HD on YouTube by click here.

 

 

Example’s Source

package com.gamefromscratch;

import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input;
import com.badlogic.gdx.InputProcessor;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.g2d.Sprite;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureAtlas;
import com.badlogic.gdx.graphics.g2d.TextureRegion;

public class SpritesheetDemo extends ApplicationAdapter implements InputProcessor {
   SpriteBatch batch;
   TextureAtlas textureAtlas;
   Sprite sprite;
   TextureRegion textureRegion;
   int currentFrame = 1;
   int MAX_FRAMES = 19;
   
   @Override
   public void create () {
      batch = new SpriteBatch();
      textureAtlas = new TextureAtlas(Gdx.files.internal("ss.txt"));
      textureRegion = textureAtlas.findRegion("0001");
      sprite = new Sprite(textureRegion);
      sprite.setPosition(Gdx.graphics.getWidth()/2 - sprite.getWidth()/2,
            Gdx.graphics.getHeight()/2 - sprite.getHeight()/2);

      Gdx.input.setInputProcessor(this);
   }

   @Override
   public void render () {
      Gdx.gl.glClearColor(0, 0, 0, 1);
      Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
      batch.begin();
      sprite.draw(batch);
      batch.end();
   }

   @Override
   public boolean keyDown(int keycode) {
      if(keycode == Input.Keys.UP){
         currentFrame++;
         if(currentFrame > MAX_FRAMES)
            currentFrame = 1;
         sprite.setRegion(textureAtlas.findRegion(String.format("%04d",currentFrame)));
      }
      if(keycode == Input.Keys.DOWN){
         currentFrame--;
         if(currentFrame < 1)
            currentFrame = MAX_FRAMES;

         sprite.setRegion(textureAtlas.findRegion(String.format("%04d",currentFrame)));
      }
      return true;
   }

   @Override
   public boolean keyUp(int keycode) {
      return false;
   }

   @Override
   public boolean keyTyped(char character) {
      return false;
   }

   @Override
   public boolean touchDown(int screenX, int screenY, int pointer, int button) {
      return false;
   }

   @Override
   public boolean touchUp(int screenX, int screenY, int pointer, int button) {
      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;
   }
}

 

The sprite art used for this example was rendered using this Blender file.

The texture packing application (near the end) was CodeAndWeb’s TexturePacker.

Programming


AppGameKit Studio

See More Tutorials on DevGa.me!

Month List