Hope Game
Logo Glusoft
Glusoft

Creating a 2D Game

In this tutorial we will make a simple logic puzzle game: Take Ten (number game) with libGDX.

Take Ten

Summary


You can play the game online on Kongregate
You need to be familiar with Object-oriented programming, if not the tutorial will probably be too difficult. :(

Setting up libGDX

You can follow the libGDX wiki to configure and create a project for libGDX:

Rules of the game

The rules of the games are simple we will put these instrctions in the How To page of the game:

The goal is to cross every number. You may cross two adjacent numbers by following at least one of these rules:
	- The two numbers are identical
	- The sum of the two numbers is ten
	
	- Two numbers separated with crossed numbers are adjacent
	- The first and the last number are adjacent
	
When there are no more solutions, the game adds new numbers from the previous lines.
The game may not be solvable.

The Event Handler class

In this class we will inherit the InputProcessor class and we will use the previous functions.
This class is small and use function from the main class Numbers.

public class EventHandler implements InputProcessor {
    private Numbers num;
    private TakeTen game;

	// Constructor
    public EventHandler(Numbers num, TakeTen game) {
        this.game = game;
        this.num = num;
    }

	// Exit the game or return to the menu 
    public boolean keyDown (int keycode) {
        if(keycode == Input.Keys.ESCAPE) {
            Gdx.app.exit();
        } else if(keycode == Input.Keys.BACK || keycode == Input.Keys.BACKSPACE || keycode == Input.Keys.LEFT) {
            game.setScreen(new Menu(game));
        }

        return false;
    }

	// Nothing to do
    public boolean keyUp (int keycode) {
        return false;
    }

	// Nothing to do
    public boolean keyTyped (char character) {
        return false;
    }
	
	// Nothing to do
	public boolean mouseMoved (int x, int y) {
        return false;
    }

	// Select the number when the mouse is pressed
    public boolean touchDown (int x, int y, int pointer, int button) {
        if(num.isSelected()) {
            num.setTouchDown2(x, y, pointer, button);
        } else {
            num.setTouchDown(x, y, pointer, button);
        }

        return false;
    }

	// When the button is release
    public boolean touchUp (int x, int y, int pointer, int button) {
        num.setTouchUp(x, y);
        return false;
    }

	// Update the position when selecting a number
    public boolean touchDragged (int x, int y, int pointer) {
        if(num.isNumSelected()) {
            if(num.isSelected2()) {
                float diff = Gdx.graphics.getHeight()- num.getPosSel2().y - y;
                num.setPosition(num.getX(), num.getY() + diff);
                num.setPositionSel2(x, y);
            } else {
                num.setPositionSel(x - 16, y + 16);
            }
        } else {
            float diff = Gdx.graphics.getHeight()- num.getPosSel2().y - y;
            num.setPosition(num.getX(), num.getY() + diff);

            num.setPositionSel2(x, y);
        }
        return false;
    }

	// Update when the game is scrolled
    public boolean scrolled (int amount) {
        if(amount > 0) {
            num.setPosition(num.getX(), num.getY() + 15);
        } else {
            num.setPosition(num.getX(), num.getY() - 15);
        }
        return false;
    }
}

The main class Numbers

This class will stores every numbers on the screen and implement the rules of the games.

First let's see the attributes of the class:

private Array numbers; // Array of number
private Texture numTex; // Texture atlas of numbers
private int size; // size of a number in pixel (numbers are square images)
private float x ,y; // position of the screen
private Array rects; // sprites
private int indexSelected; // index of the selected number
private Vector2 posSelected, posSelected2; // position for the selected numbers
private boolean solutionFound; // if a solution has been found (for the solver)
private Viewport viewport; // viewport to keep the same ratio display on multiple screen
private boolean selected; // if a first number is selected
private boolean selected2; // if a number is dropped on another number
private boolean easy; // easy mode enabled
private int iSol; // index of the solution


And now let's see the list of the methods whe have to implements:
// Constructor and destructor
public Numbers(float x, float y, String filename, Viewport viewport, boolean easy)
public void dispose()

// Main methods
public boolean checkSolution2() // find a solution 
public boolean sumOrEquals(int i1, int i2) // check if the number can be crossed (see rules)
public boolean computeDistanceIndex(int i1, int i2) // Compute the distance between two numbers
public void setTouchDown(int x, int y, int pointer, int button) // call after a mouse button is pressed when nothing is selected
public void setTouchDown2(int x, int y, int pointer, int button) // call after a mouse button is pressed when a number is selected 
public void checkBlackLine() // Check when there are a line with every numbers crossed
public void setTouchUp(int x, int y) // call when a mouse button is released
public void generateNewLine() // generate a new line of numbers
private int getIndexCollision(int xP, int yP) // get the index of the number collided
public void addNumber(Integer i) // add number to the array
public void render(SpriteBatch batch) // rendering method

// Getter and Setter methods
public boolean isNumSelected()
public Vector2 getPosSel2()
public float getX()
public float getY()
public boolean isSelected()
public boolean isSelected2()
public void setPositionSel(int x, int y)
public void setPositionSel2(int x, int y)
public void setPosition(float x, float y)

This seem like a lot of works but every method is short so it's not very complicated :).

Constructor and destructor

public Numbers(float x, float y, String filename, Viewport viewport, boolean easy) {
    this.selected = false;
    this.selected = false;
    this.easy = easy;
    this.viewport = viewport;
    this.indexSelected = -1;
    this.x = x;
    this.y = y;
    this.size = 32;
    this.solutionFound = true;
    this.iSol = 0;
    numTex = new Texture(Gdx.files.internal(filename));
    numTex.setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear);
    numbers = new Array(900);
    rects = new Array(10);
    rects.add(new TextureRegion(numTex, 96, 64, 32, 32)); // 0
    rects.add(new TextureRegion(numTex, 0, 96, 32, 32)); // 1
    rects.add(new TextureRegion(numTex, 32, 96, 32, 32)); // 2
    rects.add(new TextureRegion(numTex, 0, 64, 32, 32)); // 3
    rects.add(new TextureRegion(numTex, 64, 96, 32, 32)); // 4
    rects.add(new TextureRegion(numTex, 96, 96, 32, 32)); // 5
    rects.add(new TextureRegion(numTex, 32, 64, 32, 32)); // 6
    rects.add(new TextureRegion(numTex, 0, 32, 32, 32)); // 7
    rects.add(new TextureRegion(numTex, 0, 0, 32, 32)); // 8
    rects.add(new TextureRegion(numTex, 64, 64, 32, 32)); // 9

    solutionFound = checkSolution2();
}

public void dispose() {
    numTex.dispose();
}

Find a solution checkSolution2()

// Find the index of a solution and return true if one is found
public boolean checkSolution2() {
    for(int i = 0; i < numbers.size; i++) {
        for (int j = 0; j < numbers.size; j++) {
            if(sumOrEquals(i, j)) {
                //Gdx.app.log("solution", i + ", " + j);
                iSol = i;
                return true;
            }
        }
    }
    return false;
}

Check if the number can be crossed

// Return true if the number can be crossed
public boolean sumOrEquals(int i1, int i2) {
    int iMin = 0;
    for(int i = 0; i < numbers.size; i++) {
        if(numbers.get(i) != 0) {
            iMin = i;
            break;
        }
    }

    int iMax = 0;
    for(int i = numbers.size - 1; i >=0; i--) {
        if(numbers.get(i) != 0) {
            iMax = i;
            break;
        }
    }


    if(i1 == i2 || i1 >= numbers.size || i2 >= numbers.size || i1 < 0 || i2 < 0) {
        return false;
    }
    else if(numbers.get(i1).equals(0)) {
        return false;
    } else if(numbers.get(i2).equals(0)) {
        return false;
    } else if((i1 == iMin && i2 == iMax) || (i1 == iMax && i2 == iMin)) {
        return numbers.get(i1).equals(numbers.get(i2)) || (numbers.get(i1) + numbers.get(i2)) == 10;
    }
    else if(computeDistanceIndex(i1, i2)) {
        return numbers.get(i1).equals(numbers.get(i2)) || (numbers.get(i1) + numbers.get(i2)) == 10;
    }
    return false;
}

Check the distance between two numbers

// If the distance between two numbers is exactly one return true
public boolean computeDistanceIndex(int i1, int i2) {
    int min = 0;
    int max = 0;

    if(i1 > i2) {
        min = i2;
        max = i1;
    } else if(i2 > i1) {
        min = i1;
        max = i2;
    } else {
        return false;
    }

    int latDistance = 0;
    for(int i = (min+1); i <= max; i++) {
        if(numbers.get(i) != 0) {
            latDistance++;
        }
    }

    int vertDistance = 0;
    if(min%9 == max%9) {
        for (int i = min + 9; i <= max; i = i + 9) {
            if (i < numbers.size) {
                if (numbers.get(i) != 0) {
                    vertDistance++;
                }
            }
        }
    }

    if(vertDistance == 0 && latDistance == 0) {
        return false;
    } else if(vertDistance == 1 || latDistance == 1) {
        return true;
    }

    return false;
}

When something is selected

public void setTouchDown(int x, int y, int pointer, int button) {
    if(button == Input.Buttons.LEFT) {
        int index = getIndexCollision(x, y);

        if(index >= 0 && numbers.get(index) != 0) {
            indexSelected = index;
            float diff = Gdx.graphics.getHeight()/ Gdx.graphics.getWidth() - 800/480;
            float ratio = 1.0f + diff;

            setPositionSel((int)(x - 16*ratio), (int)(y + 16*ratio));
            selected = true;
        } else {
            setPositionSel2(x, y);
        }
    }
}

If a number is dropped on another number

public void setTouchDown2(int x, int y, int pointer, int button) {
    if(button == Input.Buttons.LEFT) {
        setPositionSel2(x, y);
        selected2 = true;
    }
}

If there a line with every numbers crossed

public void checkBlackLine() {
    boolean done = false;
    for(int i = 0; i < numbers.size; i = i + 9) {
        boolean check = true;

        for(int j = 0; j < 9; j++) {
            if((i+j) < numbers.size) {
                if(numbers.get(i+j) != 0) {
                    check = false;
                    break;
                }
            } else {
                check = false;
                break;
            }
        }

        if(check) {
            for(int j = 0; j < 9; j++) {
                numbers.removeIndex(i);
            }
            done = true;
        }
    }

    if(done) {
        checkBlackLine();
    }
}

When a button is released

// Generate a new line of number and delete line of crossed number if needed
public void setTouchUp(int x, int y) {
    if(indexSelected >= 0) {
        int indexDrop = getIndexCollision(x, y);
        if(indexDrop >= 0) {
            if(sumOrEquals(indexDrop, indexSelected)) {
                numbers.set(indexDrop, 0);
                numbers.set(indexSelected, 0);

                //Delete black lines
                checkBlackLine();

                solutionFound = checkSolution2();
                if(!solutionFound) {
                    generateNewLine();
                }
            }
        }
        indexSelected = -1;

        if(selected2) {
            selected2 = false;
        } else if(selected) {
            selected = false;
        }
    }
}

Generate a new line

// Generate a new line and find a new solution
public void generateNewLine() {
    int size = numbers.size;
    for(int i = 0; i < size; i++) {
        if(numbers.get(i) != 0) {
            addNumber(numbers.get(i));
        }
    }
    solutionFound = checkSolution2();
}

Find the index of the number at position xP, yP

// If the number is found return the index else return -1
private int getIndexCollision(int xP, int yP) {
    //Gdx.app.log("Col pos", xP + ", " + yP);

    for(int i = 0; i <= numbers.size/9; i++) {
        for(int j = 0; j < 9; j++) {
            //font.draw(batch, Integer.toString(numbers.get(i*9+j)), x + size*j, y + size*i);
            if (i * 9 + j < numbers.size) {
                Array r = new Array(4);
                r.add(viewport.project(new Vector2(x + size*j, y - size*i)));
                r.add(viewport.project(new Vector2(x + size*j + 32, y - size*i)));
                r.add(viewport.project(new Vector2(x + size*j + 32, y - size*i + 32)));
                r.add(viewport.project(new Vector2(x + size*j, y - size*i + 32)));

                if(Intersector.isPointInPolygon(r, new Vector2(xP, Gdx.graphics.getHeight() - yP))) {
                    return i*9+j;
                }
            }
        }
    }
    return -1;
}

Add a number

public void addNumber(Integer i) {
    numbers.add(i);
}

Render everything on the screen

public void render(SpriteBatch batch) {
    for(int i = 0; i <= numbers.size/9; i++) {
        for(int j = 0; j < 9; j++) {
            batch.setColor(Color.BLACK);
            if(i*9+j < numbers.size) {
                if(indexSelected < 0) { // No number selected
                    if(easy && (i * 9 + j) == iSol) {
                        batch.setColor(Color.RED);
                    }

                    batch.draw(rects.get(numbers.get(i * 9 + j)), x + size * j, y - size * i);
                } else if(indexSelected != i*9+j) { // A number is selected
                    if(easy && (i * 9 + j) == iSol) {
                        batch.setColor(Color.RED);
                    }

                    batch.draw(rects.get(numbers.get(i * 9 + j)), x + size * j, y - size * i);
                } else {
                    Vector2 v = viewport.unproject(new Vector2(posSelected.x,  Gdx.graphics.getHeight() - posSelected.y));
                    if(easy && (i * 9 + j) == iSol) {
                        batch.setColor(Color.RED);
                    }

                    batch.draw(rects.get(numbers.get(i * 9 + j)), v.x, v.y);
                }
            }
        }
    }
}

Getters and setters

public boolean isNumSelected() {
    return indexSelected >= 0;
}
	
public Vector2 getPosSel2() {
    return posSelected2;
}

public float getX() {
    return x;
}

public float getY() {
    return y;
}

public boolean isSelected() {
    return selected;
}

public boolean isSelected2() {
    return selected2;
}

public void setPositionSel(int x, int y) {
    this.posSelected = new Vector2(x, Gdx.graphics.getHeight() - y);
}

public void setPositionSel2(int x, int y) {
    this.posSelected2 = new Vector2(x, Gdx.graphics.getHeight() - y);
}

public void setPosition(float x, float y) {
    this.x = x;
    this.y = y;
}

In this class we have implemented all the logic of the games. The next this to do is add a proper event handler.

The Screens classes

GameScreen

To render the game we need to have a screen, so we create a new class implementing the Screen interface. In this class, we initialize everything we need for the game such as the camera, viewport, spritebatch, event handler and the main class Numbers.

public class GameScreen implements Screen {
    final TakeTen game;
    SpriteBatch batch;
    private Numbers numbers;
    private OrthographicCamera camera;
    private Viewport viewport;
    private boolean easy;

	// Initilize the screem and the viewport 
    public GameScreen(final TakeTen game, boolean easy) {
        this.game = game;
        this.easy = easy;

        camera = new OrthographicCamera();
        viewport = new FillViewport(480, 800, camera);

        viewport.apply();
        camera.position.set(camera.viewportWidth/2,camera.viewportHeight/2,0);

        batch = new SpriteBatch();

        numbers = new Numbers(100, 700, "r-white.png", viewport, easy);

        for (int i = 1; i < 20; i++) {
            if (i == 10) {
            } else if (i > 10) {
                numbers.addNumber(1);
                numbers.addNumber(i - 10);
            } else {
                numbers.addNumber(i);
            }
        }

        Gdx.input.setCatchBackKey(true);
        Gdx.input.setInputProcessor(new EventHandler(numbers, game));
    }

    @Override
    public void render(float delta) {
        camera.update();
        batch.setProjectionMatrix(camera.combined);

        Gdx.gl.glClearColor(1, 1, 1, 1);
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

        batch.begin();
        numbers.render(batch);
        batch.end();
    }

    @Override
    public void resize(int width, int height) {
        viewport.update(width, height);
    }

    @Override
    public void show() {
    }

    @Override
    public void hide() {
    }

    @Override
    public void pause() {
    }

    @Override
    public void resume() {
    }

    @Override
    public void dispose() {
        batch.dispose();
        numbers.dispose();
    }
}

HowToScreen.java

We will need a little tutorial to explain the rules and again we need a new Screen to render the tutorial.

public class HowToScreen implements Screen {
    SpriteBatch batch;
    BitmapFont fontTitle, font;
    TakeTen game;
    Viewport viewport;
    OrthographicCamera camera;

    public HowToScreen(final TakeTen game) {
        this.game = game;

        camera = new OrthographicCamera();
        viewport = new FillViewport(480, 800, camera);

        viewport.apply();
        camera.position.set(camera.viewportWidth/2,camera.viewportHeight/2,0);

        fontTitle = new BitmapFont(Gdx.files.internal("trebuc32.fnt"), false);
        fontTitle.setColor(0, 0, 0, 1);
        fontTitle.getRegion().getTexture().setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear);

        font = new BitmapFont(Gdx.files.internal("trebuc16.fnt"), false);
        font.setColor(0, 0, 0, 1);
        font.getRegion().getTexture().setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear);

        batch = new SpriteBatch();

        InputMultiplexer multiplexer = new InputMultiplexer();
        multiplexer.addProcessor(new EventHandler2(game));
        multiplexer.addProcessor(new SwipGesture(new SwipGesture.DirectionListener() {
            @Override
            public void onUp() {
            }

            @Override
            public void onRight() {
            }

            @Override
            public void onLeft() {
                game.setScreen(new Menu(game));
            }

            @Override
            public void onDown() {
            }
        }));

        Gdx.input.setInputProcessor(multiplexer);
    }

    @Override
    public void render(float delta) {
        camera.update();
        batch.setProjectionMatrix(camera.combined);

        Gdx.gl.glClearColor(1, 1, 1, 1);
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
        batch.begin();
        fontTitle.draw(batch, "How to play?", 50, 750);
        font.draw(batch, "The goal is to cross every number.\n You may cross two adjacent numbers by following\n at least one of these rules:\n\n" +
                        "       - The two numbers are identical\n" +
                        "       - The sum of the two numbers is ten\n\n" +
                        "       - Two numbers separated with crossed \n" +
                        "         numbers are adjacent\n" +
                        "       - The first and the last number are adjacent\n\n" +
                        " When there are no more solutions, the game adds\n new numbers from the previous lines.\n\n" +
                        " The game may not be solvable.", 50, 700);
        batch.end();
    }

    @Override
    public void resize(int width, int height) {
        viewport.update(width, height);
    }

    @Override
    public void show() {
    }

    @Override
    public void hide() {
    }

    @Override
    public void pause() {
    }

    @Override
    public void resume() {
    }

    @Override
    public void dispose() {
        batch.dispose();
        font.dispose();
        fontTitle.dispose();
    }
}

Menu

We need to have a main menu to choose between the tutorial and the game

public class Menu implements Screen {
    final TakeTen game;
    Skin skin;
    Stage stage;

    public Menu(TakeTen pgame){
        this.game = pgame;

        stage = new Stage(new StretchViewport(480, 800));

        Gdx.input.setInputProcessor(stage);

        skin = new Skin( Gdx.files.internal( "uiskin.json" ));

        Table table=new Table();
        table.setSize(480, 800);

        final TextButton startGame = new TextButton("Start game",skin);
        table.add(startGame).width(150).height(50);
        table.row();

        final TextButton tuto = new TextButton("Tutorial",skin);
        table.add(tuto).width(150).padTop(10).padBottom(3);
        table.row();

        final TextButton howto = new TextButton("How to play?",skin);
        table.add(howto).width(150).padTop(10).padBottom(3);
        table.row();

        TextButton quit = new TextButton("quit",skin);
        table.add(quit).width(150).padTop(10);
        table.row();

        stage.addActor(table);

        startGame.addListener(new ClickListener(){
            @Override
            public void clicked(InputEvent event, float x, float y) {
                startGame.addAction(Actions.fadeOut(0.7f));
                game.setScreen(new GameScreen(game, false));
            }
        });

        howto.addListener(new ClickListener(){
            @Override
            public void clicked(InputEvent event, float x, float y) {
                howto.addAction(Actions.fadeOut(0.7f));
                game.setScreen(new HowToScreen(game));
            }
        });

        tuto.addListener(new ClickListener(){
            @Override
            public void clicked(InputEvent event, float x, float y) {
                tuto.addAction(Actions.fadeOut(0.7f));
                game.setScreen(new GameScreen(game, true));
            }
        });

        quit.addListener(new ClickListener(){
            @Override
            public void clicked(InputEvent event, float x, float y) {
                Gdx.app.exit();
            }
        });
    }

    @Override
    public void render(float delta) {
        // clear the screen
        Gdx.gl.glClearColor(1, 1, 1, 1);
        Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

        // let the stage act and draw
        stage.act(delta);
        stage.draw();
    }

    @Override
    public void resize(int width, int height) {
        stage.getViewport().update(width, height, false);
    }

    @Override
    public void show() {
    }

    @Override
    public void hide() {
        stage.dispose();
    }

    @Override
    public void pause() {
    }

    @Override
    public void resume() {
    }

    @Override
    public void dispose() {
        stage.dispose();
    }
}

The game class TakeTen

The main class of the Game is rather small, we just need to render the starting point of the game : the menu

public class TakeTen extends Game {
	SpriteBatch batch;
	BitmapFont font;

	public void create() {
		batch = new SpriteBatch();
		font = new BitmapFont();
		this.setScreen(new Menu(this));
	}

	public void render() {
		super.render();
	}

	public void dispose() {
		batch.dispose();
		font.dispose();
	}
}

We have everything we need now we can play the game! But wait there is no fonts and no assets just the code.
We need to have the files for the font Trebuchet 16 and trebuchet 32
To generate the font file you can use Hiero, it's a bitmap font packing tool.

Each time you want to use a font you will need for a specific size two files :
To save you the trouble you can download the fonts : fonts.zip
You will need to have a uiskin file to have a skin for the menu : uiskin.zip you can also download the skin from the tests in libgdx

For the numbers we will use some images :
numbers

We have everything we need know to have something working.

The game is simple, but even a simple game can have some boilerplate code but what can we do (maybe use Unity :P).

Download the project

You can download the source of the project on gamejolt!

Dark theme