DEV Community

Cover image for Oh, the Places You'll Go! - A Guide to Character Movement in libGDX
mirjamuher
mirjamuher

Posted on

Oh, the Places You'll Go! - A Guide to Character Movement in libGDX

Magika moving in a circle in her bedroom

"First we'll move our wee Vector2! This float pair will become dear to you. Once we have an x and a y, I think you and I, will have little work left to do."
- adapted from the libGDX Discord

Are you new to libGDX and are struggling with character movement in 2D? Well, fellow explorer of a seemingly rule-less world, you've come to the right place.

I'm 250 hours into programming a Stardew Valley inspired 2D RPG - and let me tell you, it's been a ride. I'm still relatively new to programming, so it's been cool learning about fancy software patterns, wrangle a big project, and become besties with Ms. Red Debug Circle.

But we definitely encountered some meltdowns along the way. Especially in the beginning I struggled with figuring out the big design decisions. libGDX gives you all the freedom in the world, but sometimes that can feel a little overwhelming.

That's why I'll be showing you today how I answered the question "But how do I get my character moving?!".

Laying the land

"libGDX is a cross-platform Java game development framework. [It] does not force a specific design or coding style on you; it rather gives you the freedom to create a game the way you prefer".
- from https://libgdx.com/

Before diving into the specifics of translating user input into 2D character movement, let's go over a few ground rules so we can keep this post focussed on the topic of movement.

This post assumes that you have a basic understanding of how to set up a libGDX project and create a game. If not, don't worry! There are many tutorials available, such as my personal favourite, ColdWild's Multiplatform tutorial.

I also will not be covering map creation in this post, but I highly recommend using Tiled for all your map-making needs.

Nevertheless, if you have any libGDX questions, feel free to reach out or comment on the blog. I'll do my best to answer and if it's a bigger topic, I might tackle it in a future post.


Oh, the places you'll go

- How to set up your Player Actor -

Magika exploring the world Magika exploring the world Magika exploring the world

Player Object

The first thing you'll want to do is create your Player object - the representative of our user, the star of the show, the walking impersonation of main-character-syndrome. But instead of giving them a personality and a story, we set up:

  • a Sprite (the picture of the Player),
  • the Actor (a 2D scene graphic node, aka a sprite that has a location and size that can be tracked by Scene2D and can act upon events, and
  • the Box2D body

Note: the below code has been stripped from everything not related to movement. Your Player will probably be quite a big more extensive

//Player.java
public class Player extends Actor {
   [...]

    public Player(Vector2 startPosition) {
        super();
        sprite = [sprite retrieving logic];

        // Set the actor's x and y coordinates, as well as width and height.
        setBounds(startPosition.x, startPosition.y, sprite.getWidth(), sprite.getHeight());

        // Create the Box2D body for the Player
        physicalBody = [libgdx Body creation logic]

        // connect Actor with Box2D body
        physicalBody.setUserData(this); 

        [...]
     }
Enter fullscreen mode Exit fullscreen mode

In the next step, we will want to ensure that our Player moves at the correct speed. To do so, we call trackMovement whenever our Player(which is an Actor) is called upon to act.

//Player.java
public class Player extends Actor {
   [...]

    public float speed = 5 * Resources.TILE_SIZE; // = 5 * 48
    private Vector2 currentVelocity = new Vector2(0, 0); // starting velocity is 0 in either direction x or y; aka we're standing still

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

        trackMovement(delta);
    }

    private void trackMovement(float delta) {
        // calculate how far the body has moved by multiplying the constant speed by how much time has elapsed 
        float movement = delta * speed;

        // move the physical body by multiplying current velocity with the movement
        physicalBody.setLinearVelocity(currentVelocity.cpy().scl(movement));

        // move sprite by moving actor to the same coordinates as the physical body
        this.setPosition(physicalBody.getPosition().x - Resources.WORLD_TILE_SIZE / 2f, physicalBody.getPosition().y - Resources.WORLD_TILE_SIZE / 2f);
    }
Enter fullscreen mode Exit fullscreen mode

Prize question: Where will our character move where while
currentVelocity == Vector2(0, 0)?
Answer: Nowhere.

So how do we get out of The Waiting Place?

Magika, waiting

"Waiting for a train to go or a bus to come,
or a plane to go or the mail to come,
or the rain to go or the phone to ring,
or the snow to snow or waiting around for a Yes or No
or waiting for their hair to grow.

Everyone is just waiting."
- Dr. Seuss


Capturing Inputs with an InputListener

Within Player

My Player class listens to a set of InputListeners; which one depends on its current state.
When the Player is walking around freely, it listens to input events relating to movement, environment checks, etc. But, for example, when the Player is sitting, movement inputs will be ignored while Player reacts to other inputs directing sitting-related tasks.

For the sake of this tutorial, however, we'll only be adding one listener to our Player: the FreeRoamingMovementListener.

//Player.java
    public Player(Vector2 startPosition) {
        [...]
        addListener(FreeRoamingMovementListener(this));
    }
Enter fullscreen mode Exit fullscreen mode

Within FreeRoamingMovementListener

This listener is all about capturing user input and translating it into movement. To do so, it overrides both the method keyDown (so we keep applying movement while the key is pressed) as well as keyUp (in order to stop the moment a key is released)

"EventListeners are added to actors to be notified about events. For example, InputListener is provided for receiving and handling InputEvents. An actor just needs to add an InputListener to start receiving input events. [It] has several methods that may be overridden."
- From the Wiki

@Override keyDown()

When a key is pressed, it goes through several steps:

  1. Keep track of all pressed keys (in pressedKeyCodes)
  2. Determines the state of the Player - is it moving North, East, South-West, ...?
  3. Calculates the corresponding force/velocity on the Player's Box2D Body & Sprite
  4. Informs Player of its new state
//FreeRoamingMovementListener.java
class FreeRoamingMovementListener extends InputListener {
    private final Set<Integer> pressedKeyCodes = new HashSet<>(); // note: chose a set because we care about values being unique and want efficient lookups

    [...]

    @Override
    public boolean keyDown(InputEvent event, int keycode) {
        pressedKeyCodes.add(keycode);

        // Step 1: Determining the state.
        PlayerState state = getPlayerStateBasedOnCurrentlyPressedKeys();
        if (state == null) {
            pressedKeyCodes.remove(keycode);
            return false;
        }

        // Step 2: Translate the direction the player is facing into velocity
        Vector2 newVelocity = state.calculateDirectionVector();

        // Step 3: Update velocity & state in `Player`
        player.setStateAndVelocity(state, newVelocity);

        return true;
    }
Enter fullscreen mode Exit fullscreen mode

Let's cover each step in more detail.

Step 1: Determining the state.

With the press of W, S, A, or D, our Player can move North, South, West and East respectively; on an especially fancy day, we can even head North-East, South-West, and all points in between. The trick is to keep track of that state to determine things like the correct sprite, animation and interactionable-object search radius.

I use enum PlayerState to keep track of a Player's movement type and direction (see its definition under Step 2), and the following code helps us find the right state for the given input.

Note: This code needs a bit of fine-tuning to avoid preferring certain directions. But for now, it'll do the trick.

//FreeRoamingMovementListener.java
class FreeRoamingMovementListener extends InputListener {
    [...]

    private PlayerState getPlayerStateBasedOnCurrentlyPressedKeys() {
        // get sprite based on walking direction
        if (pressedKeyCodes.contains(Input.Keys.UP)) {
            if (pressedKeyCodes.contains(Input.Keys.RIGHT)) {
                return PlayerState.WALKING_NE;
            } else if (pressedKeyCodes.contains(Input.Keys.LEFT)) {
                return PlayerState.WALKING_NW;
            } else {
                return PlayerState.WALKING_N;
            }
        } else if (pressedKeyCodes.contains(Input.Keys.DOWN)) {
            if (pressedKeyCodes.contains(Input.Keys.RIGHT)) {
                return PlayerState.WALKING_SE;
            } else if (pressedKeyCodes.contains(Input.Keys.LEFT)) {
                return PlayerState.WALKING_SW;
            } else {
                return PlayerState.WALKING_S;
            }
        } else if (pressedKeyCodes.contains(Input.Keys.RIGHT)) {
            return PlayerState.WALKING_E;
        } else if (pressedKeyCodes.contains(Input.Keys.LEFT)) {
            return PlayerState.WALKING_W;
        } else {
            return null;
        }
    }
Enter fullscreen mode Exit fullscreen mode

Step 2: Translate the direction the Player is facing into velocity

I use a Vector2 to help me determine which direction on a xy-axis the Player is facing. If it's headed north, we'll increase the y-axis by setting DirectionVector = Vector2(0, 1);. But if it's headed south, we'll have to go the opposite direction with DirectionVector = Vector2(0, -1);.

//PlayerState.java
public enum PlayerState {
    STANDING_S, STANDING_N, STANDING_E, STANDING_W,
    WALKING_S, WALKING_N, WALKING_E, WALKING_W, WALKING_NE, WALKING_SE, WALKING_SW, WALKING_NW,
    SITTING_W,
    PICKUP_S, PICKUP_N, PICKUP_E, PICKUP_W,
    HOLD_S,
    ;

    private static final float ONE_ON_ROOT_TWO = (float) (1.0 / Math.sqrt(2));

    public Vector2 calculateDirectionVector() {
        return switch (this) {
            case WALKING_N, STANDING_N -> new Vector2(0, 1);
            case WALKING_S, STANDING_S -> new Vector2(0, -1);
            case WALKING_E, STANDING_E -> new Vector2(1, 0);
            case WALKING_W, STANDING_W -> new Vector2(-1, 0);
            case WALKING_NE -> new Vector2(ONE_ON_ROOT_TWO, ONE_ON_ROOT_TWO);
            case WALKING_NW -> new Vector2(-ONE_ON_ROOT_TWO, ONE_ON_ROOT_TWO);
            case WALKING_SE -> new Vector2(ONE_ON_ROOT_TWO, -ONE_ON_ROOT_TWO);
            case WALKING_SW -> new Vector2(-ONE_ON_ROOT_TWO, -ONE_ON_ROOT_TWO);
            case SITTING_W -> new Vector2(0, 0);
            case PICKUP_S, PICKUP_E, PICKUP_W, PICKUP_N -> new Vector2(0, 0);
            case HOLD_S -> new Vector2(0, 0);
        };
    }
Enter fullscreen mode Exit fullscreen mode

Step 3: Update velocity in Player

Now we've determined the direction our Player should be heading, it's time to get moving!

Back in Player class, we use the newly calculated velocity and set it as our currentVelocity. And by applying it to the Box2d Body, the Player has started moving in the direction of our choice.

//Player.java
public class Player extends Actor {
    private Vector2 currentVelocity = new Vector2(0, 0);
   [...]

    void setStateAndVelocity(PlayerState newState, Vector2 newVelocity) {
        // Update our velocity and enact it on our physical body.
        currentVelocity = newVelocity;
        physicalBody.setLinearVelocity(currentVelocity);

        // Update our state and let the sprite animator know of our new state.
        [...]

        // Trigger hooks upon a state change.
        [...]
    }

Enter fullscreen mode Exit fullscreen mode

Remember the question I asked earlier when we looked at trackMovement()? Let's turn it around.
What happens to Player, while currentVelocity != Vector2(0, 0);?

    private void trackMovement(float delta) {
        float movement = delta * speed;
        physicalBody.setLinearVelocity(currentVelocity.cpy().scl(movement));
        this.setPosition(physicalBody.getPosition().x - Resources.WORLD_TILE_SIZE / 2f, physicalBody.getPosition().y - Resources.WORLD_TILE_SIZE / 2f);
    }
Enter fullscreen mode Exit fullscreen mode

... Right! Our character starts going places!

Magika, going places

@Override keyUp()

When a key gets released, our Player can take a break from moving in that direction. We remove the key from the pressedKeyCodes and recalculate the Player's state and velocity, just like we did when the key was first pressed.

//FreeRoamingMovementListener.java
    @Override
    public boolean keyUp(InputEvent event, int keycode) {
        pressedKeyCodes.remove(keycode);

        // Step 1: determine PlayerState based on remaining pressed keycodes (see description for Step 1 under `keyDown`)
        PlayerState state = getPlayerStateBasedOnCurrentlyPressedKeys();

        // Step 2: Set newVelocity back to `Vector2(0, 0)` and override it once more, if applicable (see description of Step 2 under `keyDown`)
        Vector2 newVelocity = Vector2.Zero;
        if (state != null) {
            newVelocity = state.calculateDirectionVector();
        }

        // Step 3: Update the player's state (see description of Step 3 under `keyDown`)
        updatePlayerState(state, newVelocity);

        return true;
    }
Enter fullscreen mode Exit fullscreen mode

And that's it!


Concluding thoughts... get on your way!

"You tried to escape math while at school.
You copied, and you scraped, and you hid like a fool.
Well, enduring soul, as you shall see soon,
What once was a game now turned into
a living nightmare because Vector Math has become as inescapable as death and taxes.

... and that's on you."
- by yours truly

Let's put it all together!

//Player.java
public class Player extends Actor { 
    public float speed = 5 * Resources.TILE_SIZE;
    private Vector2 currentVelocity = new Vector2(0, 0);

    public Player(Vector2 startPosition) {
        super();
        sprite = [sprite retrieving logic];

        // Set the actor's x and y coordinates, as well as width and height.
        setBounds(startPosition.x, startPosition.y, sprite.getWidth(), sprite.getHeight());

        // Create the Box2D body for the Player
        physicalBody = [libgdx Body creation logic]

        // connect Actor with Box2D body
        physicalBody.setUserData(this); 

        addListener(FreeRoamingMovementListener(this));
     }

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

        trackMovement(delta);
    }

    private void trackMovement(float delta) {
        // calculate how far the body has moved by multiplying the constant speed by how much time has elapsed 
        float movement = delta * speed;

        // move the physical body by multiplying current velocity with the movement
        physicalBody.setLinearVelocity(currentVelocity.cpy().scl(movement));

        // move sprite by moving actor to the same coordinates as the physical body
        this.setPosition(physicalBody.getPosition().x - Resources.WORLD_TILE_SIZE / 2f, physicalBody.getPosition().y - Resources.WORLD_TILE_SIZE / 2f);
    }

    void setStateAndVelocity(PlayerState newState, Vector2 newVelocity) {
        // Update our velocity and enact it on our physical body.
        currentVelocity = newVelocity;
        physicalBody.setLinearVelocity(currentVelocity);

        // Update our state and let the sprite animator know of our new state.
        [...]

        // Trigger hooks upon a state change.
        [...]
    }
}
Enter fullscreen mode Exit fullscreen mode
//FreeRoamingMovementListener.java
class FreeRoamingMovementListener extends InputListener {
    private final Player player;
    private final Set<Integer> pressedKeyCodes = new HashSet<>();

    public FreeRoamingMovementListener(Player player) {
        this.player = player;
    }

    @Override
    public boolean keyDown(InputEvent event, int keycode) {
        pressedKeyCodes.add(keycode);

        // Step 1: Determining the state.
        PlayerState state = getPlayerStateBasedOnCurrentlyPressedKeys();
        if (state == null) {
            pressedKeyCodes.remove(keycode);
            return false;
        }

        // Step 2: Translate the direction the player is facing into velocity.
        Vector2 newVelocity = state.calculateDirectionVector();

        // Step 3: Update velocity & state in `Player`.
        player.setStateAndVelocity(state, newVelocity);

        return true;
    }

    @Override
    public boolean keyUp(InputEvent event, int keycode) {
        pressedKeyCodes.remove(keycode);

        // Step 1: Determining the state.
        PlayerState state = getPlayerStateBasedOnCurrentlyPressedKeys();

        // Step 2: Translate the state of player into velocity or default to Vector2.Zero.
        Vector2 newVelocity = Vector2.Zero;
        if (state != null) {
            newVelocity = state.calculateDirectionVector();
        }

        // Step 3: Update velocity & state in `Player`.
        updatePlayerState(state, newVelocity);

        return true;
    }

    private PlayerState getPlayerStateBasedOnCurrentlyPressedKeys() {
        // get sprite based on walking direction
        if (pressedKeyCodes.contains(Input.Keys.UP)) {
            if (pressedKeyCodes.contains(Input.Keys.RIGHT)) {
                return PlayerState.WALKING_NE;
            } else if (pressedKeyCodes.contains(Input.Keys.LEFT)) {
                return PlayerState.WALKING_NW;
            } else {
                return PlayerState.WALKING_N;
            }
        } else if (pressedKeyCodes.contains(Input.Keys.DOWN)) {
            if (pressedKeyCodes.contains(Input.Keys.RIGHT)) {
                return PlayerState.WALKING_SE;
            } else if (pressedKeyCodes.contains(Input.Keys.LEFT)) {
                return PlayerState.WALKING_SW;
            } else {
                return PlayerState.WALKING_S;
            }
        } else if (pressedKeyCodes.contains(Input.Keys.RIGHT)) {
            return PlayerState.WALKING_E;
        } else if (pressedKeyCodes.contains(Input.Keys.LEFT)) {
            return PlayerState.WALKING_W;
        } else {
            return null;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
//PlayerState.java
public enum PlayerState {
    STANDING_S, STANDING_N, STANDING_E, STANDING_W,
    WALKING_S, WALKING_N, WALKING_E, WALKING_W, WALKING_NE, WALKING_SE, WALKING_SW, WALKING_NW,
    SITTING_W,
    PICKUP_S, PICKUP_N, PICKUP_E, PICKUP_W,
    HOLD_S,
    ;

    private static final float ONE_ON_ROOT_TWO = (float) (1.0 / Math.sqrt(2));

    public Vector2 calculateDirectionVector() {
        return switch (this) {
            case WALKING_N, STANDING_N -> new Vector2(0, 1);
            case WALKING_S, STANDING_S -> new Vector2(0, -1);
            case WALKING_E, STANDING_E -> new Vector2(1, 0);
            case WALKING_W, STANDING_W -> new Vector2(-1, 0);
            case WALKING_NE -> new Vector2(ONE_ON_ROOT_TWO, ONE_ON_ROOT_TWO);
            case WALKING_NW -> new Vector2(-ONE_ON_ROOT_TWO, ONE_ON_ROOT_TWO);
            case WALKING_SE -> new Vector2(ONE_ON_ROOT_TWO, -ONE_ON_ROOT_TWO);
            case WALKING_SW -> new Vector2(-ONE_ON_ROOT_TWO, -ONE_ON_ROOT_TWO);
            case SITTING_W -> new Vector2(0, 0);
            case PICKUP_S, PICKUP_E, PICKUP_W, PICKUP_N -> new Vector2(0, 0);
            case HOLD_S -> new Vector2(0, 0);
        };
    }
Enter fullscreen mode Exit fullscreen mode

I really hope this blog post helps you along your journey.

Let me know if you have any questions, suggestions, ideas or want to just show me how your character moves around! I'm always keen to get in touch with other (game) devs!


For completeness sake

- aka some more code snippets -

In this post I sprinkled some pseudo-code in [ ] brackets to keep the code focussed. That's why below, I added the actual code called by my pseudo-brackets. I hope this proves useful and am happy to answer questions.

Get Actor's Sprite

    //Resources.java
    private Resources() {
        gameSprites = new TextureAtlas(Gdx.files.internal("packed/game.atlas"));
    }

    public Sprite createSpriteInTileSizeUnits(String spriteFileName) {
        Sprite sprite = new Sprite(gameSprites.createSprite(spriteFileName));
        resizeSpriteSizeToTileUnits(sprite);
        return sprite;
    }

    private void resizeSpriteSizeToTileUnits(Sprite s) {
        s.setSize(spriteUnitsToTileUnits(s.getWidth()), spriteUnitsToTileUnits(s.getHeight()));
    }

    public static float spriteUnitsToTileUnits(float val) {
        return val / TILE_SIZE;
    }
Enter fullscreen mode Exit fullscreen mode

Create a Box2D body

    //TiledObjectsUtil.java
    public static Body createBox(World world, float x, float y, float width, float height, boolean isStatic) {
        BodyDef def = new BodyDef();  // a description of all the physical aspects of the body (e.g. solid vs. dynamic, etc.)
        if (isStatic) {
            def.type = BodyDef.BodyType.StaticBody;
        } else {
            def.type = BodyDef.BodyType.DynamicBody;  // non-zero velocity
        }
        def.position.set(x, y);
        def.fixedRotation = true;  // true --> will not be rotating when impacted by other bodies
        Body pBody = world.createBody(def);  // initialises body, puts it into world with def physical properties.

        // give a shape to pBody enttiy
        PolygonShape shape = new PolygonShape();
        shape.setAsBox(width/2f, height/2f);  // expands out from the "center", meaning that it's actually double that for width and height. So if  you set only 32, it'll be 64 wide
        pBody.createFixture(shape, 1f);
        shape.dispose(); // keep it clean!

        return pBody;
    }
Enter fullscreen mode Exit fullscreen mode

"Congratulations!
Today is your day.
You're off to Great Places!
You're off and away!"
- Dr. Seuss

Happy coding!

a day in the life of Magika


Mirjam is a self-taught software developer and passionate board gamer. She works as a backend developer at Digio, an Australian software consultancy.

You can reach her on LinkedIn if you wanna chat about anything and everything related to tech, game dev, boardgames or the latest reality TV show you binged.

Top comments (0)