OpenFL - Movement

on tutorials rpg

In the last post we created our hero. Now lets make him move in four directions using the arrow keys. We will also implement collision detection so he doesn't run off the map or on top of rocks.

Split Map State and View

Before starting, the current Map class holds too many responsibilties. It is responsible for holding the map state (current tiles, entities) and holding the map view (what you see on screen). It is important to keep all view logic in its own class. A class with multiple responsibilities is harder to use, reuse and contains logic for two purposes which won't always go hand in hand. In the case of Map its interface is diverged into one set of methods for handling view logic and an other for game logic. This creates a monster with two sets of logic which cannot be untangled. What if you want to implement split screen? It's not possible with the current Map class.

Lets create a MapView class, it will be our visual representation of the current map and always keep in sync with it. Right now it only contains sprites for all the layers and an entity layer for entities of course. All of our map state is left in the original Map class.

class MapView extends Sprite {

    public var bottomLayer(default, null):Sprite;
    public var middleLayer(default, null):Sprite;
    public var topLayer(default, null):Sprite;

    public var entityLayer(default, null):Sprite;

    public function new(){
        super();
    }

    public function init() {
        bottomLayer = new Sprite();
        middleLayer = new Sprite();
        topLayer = new Sprite();
        entityLayer = new Sprite();

        addChild(bottomLayer);
        addChild(middleLayer);
        addChild(entityLayer);
        addChild(topLayer);
    }

    public function destroy() {
        removeChild(bottomLayer);
        removeChild(middleLayer);
        removeChild(entityLayer);
        removeChild(topLayer);

        bottomLayer = null;
        middleLayer = null;
        entityLayer = null;
        topLayer = null;
    }

}

With our view logic out of the Map class how will the mapView keep in sync with map state? The simpliest solution is to add some glue in our Game class to add and remove views on map changes.

map.addEventListener(Map.ADD_ENTITY, onAddEntity);
map.addEventListener(Map.REMOVE_ENTITY, onRemoveEntity);
map.load(_gameData.startingMap);

// add tiles to mapView
map.bottomLayer.forEachTile(addBottomTiles);
map.middleLayer.forEachTile(addMiddleTiles);
map.topLayer.forEachTile(addTopTiles);

The add entity and remove entity triggers of course when a new entity is added or removed from the map. The callback function will then add or remove it from the mapView.

public function onAddEntity(e:SimpleEvent<Entity>){
    mapView.entityLayer.addChild(e.data.bitmap);
}

This is one of the event callbacks to keep the mapView in sync. It adds new entities to the mapView. The SimpleEvent<T> is a general purpose event which holds a generic e.data property defined as T, in this case it is Entity although it can be any class choosen.

Setting up collision

Before letting our hero move, it'll first be easiest to define which tiles are walkable. The TileData class needs a new attribute to identify which is walkable or unwalkable. In our Tilesheet class we can then set the values per-tile. The rock and sign tiles will be marked as unwalkable and by default all other tiles will be walkable.

_tileDatas[16].walkable = false;
_tileDatas[32].walkable = false;
_tileDatas[64].walkable = false;
_tileDatas[65].walkable = false;
_tileDatas[80].walkable = false;

Getting collision info from the map

Next we need to add two methods to our Map, one to find out if the given coordinate is off map and an other to find out if the wanted tile is walkable.

public function isWithin(xt:Int, yt:Int):Bool {
    return xt >= 0 && yt >= 0 && yt < height && xt < width;
}

public function isWalkable(xt:Int, yt:Int):Bool {
    if(!isWithin(xt, yt)){
        return false;
    }
    return bottomLayer.getTile(xt, yt).walkable && middleLayer.getTile(xt, yt).walkable;
}

The tile from bottomLayer and middleLayer are both taken and the walkable value is computed for both. A rock will usually be on the middle layer while something like lava would be on the bottom. Checking both will ensure both cases are handled. The topLayer is special because it is always above the hero so it wouldn't make sense for it to prevent movement. Remember every game object has a reference to map when it is added to the instance map. This will make it easy to use these methods from inside the game object like you will see.

Main loop

In order to move the hero a main loop has to be created. This is a continuous process that calls all registered objects every interval, based on the framerate. Just about every game has one and is needed for anything which has to be updated every frame, like animations, tweening, artificial intelligence and physics. Since only one process is currently in our game the main loop will only update our hero.

stage.addEventListener(Event.ENTER_FRAME, this.onEnterFrame);

public function onEnterFrame(e:Event) {
    _game.update();
}

Before a new screen redraw takes place the onEnterFrame method will be called allowing the game to update one step. It will call the _game.update method which will then call update all update on all registered game objects active in the game.

public function update() {
    hero.update();
}

Movement

Entities in our RPG can only move in one of four directions at a time. Up, down, right and left, so it would make sense to place these values in a global object called Direction so the directions available are clear and it can be used anywhere.

class Direction {

    public static var NORTH:Int = 1;
    public static var SOUTH:Int = 2;
    public static var WEST:Int = 3;
    public static var EAST:Int = 4;

}

Where should the logic for movement live? The logic for deciding can the entity move in this direction and the logic for continuing movement until reaching the end destination. The logic can be placed inside the Entity class but entities know nothing of movement and should know nothing. If the logic is placed in there the Entity will have multiple responsbilities and not every entity will move the same way.

The best solution is the break out movement into its own component. A component defines functionality for an entity, it is a place for defining input, movement and whatever else. Each entity will have an update method which then calls update on all of its components.

public function update(){
    _input.update();
    _move.update();
}

The components are passed into the constructor of entity.

public function new(input:InputComponent, move:MoveComponent){
    super();
    bitmap = new Bitmap();
    _input = input;
    _input.gameObject = this;

    _move = move;
    _move.gameObject = this;
}

The InputComponent will handle the key inputs and the MoveComponent will supply an interface for requesting movements and moving the actual entity. Once the components are inside the constructor each are then set with a reference to gameObject which is the owner of the component. The components will then know the entity it is controlling. It doesn't know it is an entity but the component does need to know things like position and needs a reference to map for it to function.

The MoveComponent does two things, it has methods for requesting movement in all four directions and it will move the entity to the requested tile.

    public function moveUp():Bool {
        if(isMoving){
            throw "Already moving";
        }

        if(map.isWalkable(gameObject.xt, gameObject.yt - 1)){
            direction = Direction.NORTH;
            isMoving = true;
            return true;
        }

        return false;
    }

The moveUp will first ensure the entity currently isn't moving. It will then ensure the next tile above the entity is walkable. If the tile is walkable the component will then set the wanted direction and trigger a movement phase by setting isMoving to true.

    public function update(){
        if(isMoving){
            switch(direction){
                case Direction.SOUTH:
                    gameObject.y += speed;
                case Direction.NORTH:
                    gameObject.y -= speed;
                case Direction.WEST:
                    gameObject.x -= speed;
                case Direction.EAST:
                    gameObject.x += speed;
            }

            // Stop moving once the entity reached the next tile
            if(gameObject.isOnTile()){
                isMoving = false;
            }
        }
    }

This update method is called by the hero's update method every frame. If the entity is moving this method will move the entity a little bit in the wanted direction every frame. After moving the entity it will then check if it is perfectly on top of a tile. If that is true it means the entity arrived at its wanted destination and can stop moving.

public function isOnTile():Bool {
    return x % tileSize == 0 && y % tileSize == 0;
}

This method is defined on the base gameObject class. It is a simple way of finding out if the currently x and y positions can evenly divide by the tile size. Which if true means the game object is on a tile and not between two tiles. This is an easy way to figure out if the game object is in transition to an other tile.

There is one bug to be aware of, if the speed of entities does not perfectly multiply into the tile size entities will over shoot the target tile and continue moving, potentially forever.

Listen for key presses

We got movement figured out, we got collisions and we have a main loop for running it. The last piece is key input. A new input component is needed for listening to key presses and converting that into movement requests. First we have to listen to KeyboardEvent.KEY_DOWN events and save the current key states in a global class called KeyState. Putting key states inside a global class is great because now components don't need to add keyboard events and add all this boilerplate code to get the wanted key info. All of that is already taken care of in the KeyState class.

stage.addEventListener(KeyboardEvent.KEY_DOWN, this.onKeyDown);
stage.addEventListener(KeyboardEvent.KEY_UP, this.onKeyUp);
public function onKeyUp(e:KeyboardEvent) {
    KeyState.keysDown.set(e.keyCode, false);
}

public function onKeyDown(e:KeyboardEvent) {
    KeyState.keysDown.set(e.keyCode, true);
}

This listens to every key on your keyboard and tracks which ones are currently pressed down.

class KeyState {

    public static var keysDown:IntMap<Bool> = new IntMap<Bool>();

    public static function isDown(keyCode:UInt):Bool {
        return keysDown.get(keyCode);
    }

}

A public static method isDown is available for use anywhere in the code base. Chances are we will have multiple components and objects listening for key downs. This is a great way of reducing code duplication. You might be thinking, global methods are bad. Everything should be kept as local as possible to reduce coupling and increase reusability. That is true but not every global method or singleton is wrong. The KeyState is fine because its global method isDown can only be read, it is a one-way global object. Now if it had a global method which modified key states that would be different. Lets just pretend the keysDown map is private.

public function update(){
    if(KeyState.isDown(Keyboard.RIGHT) && !_moveComp.isMoving){
        _moveComp.moveRight();
    }

    if(KeyState.isDown(Keyboard.LEFT) && !_moveComp.isMoving){
        _moveComp.moveLeft();
    }

    if(KeyState.isDown(Keyboard.DOWN) && !_moveComp.isMoving){
        _moveComp.moveDown();
    }

    if(KeyState.isDown(Keyboard.UP) && !_moveComp.isMoving){
        _moveComp.moveUp();
    }
}

The InputComponent checks for key downs on every update and moves the entity in the wanted direction if it is not currently moving. An instance of move component has to be passed into the input component so it has access to the move methods. The Keyboard.RIGHT constants are helper constants from flash.ui.Keyboard. With all that the hero should be controllable with the arrow keys.