Making a LibGDX Roguelike Survival Game Part 10 – Adding a bird with simple AI #gamedev

[ Full source code ]

In this tutorial we will add an animated bird that will fly randomly around the island, sometimes landing, feeding and walking about. The bird extends the Entity class so can be interacted with by default and added to the Inventory, later we can look at more creative ways to interact with the Bird.  I have not used a state machine for the bird AI, but you can read up on an example of one here.

Images

core/assets/entities/bird

There are 4 images needed for the bird entity, flying, walking, feeding and a shadow. We only require the animation in one direction; the images can be flipped horizontally for the opposite, the shadow is used to give the illusion that the bird is airborne.

Media.java

The class that loads all our image assets requires Texture, TextureRegion[] and Animation variables so that the bird animations can be setup, add the class variables:

public static Texture birdWalk, birdFly, birdPeck, birdShadow;

// Texture Regions
public static TextureRegion[] birdWalkFrames, birdFlyFrames, birdPeckFrames;    

// Animations
public static Animation<TextureRegion> birdWalkAnim, birdPeckAnim, birdFlyAnim;

Loading the assets, first the textures are loaded, they are used to create a TextureRegion[]; an array of frames. We split the texture up into an array using the dimensions of the animation frames. It is possible to have more than one row of images but for simplicity we are using textures with just one so set the TextureRegion to row 0. Once we have an array of frames we can use the TextureRegion to create a new Animation, we will set the frame time to .1 seconds.

Screen Shot 2017-09-04 at 23.21.43

  public static void load_assets(){
        ...
        // Textures
        birdPeck = new Texture("entities/bird/bird_peck.png");
        birdWalk = new Texture("entities/bird/bird_walk.png");
        birdFly  = new Texture("entities/bird/bird_fly.png");
        birdShadow = new Texture("entities/bird/bird_shadow.png");

        // Texture Regions
        birdWalkFrames = TextureRegion.split(birdWalk, 10, 9)[0];
        birdPeckFrames = TextureRegion.split(birdPeck, 10, 9)[0];
        birdFlyFrames = TextureRegion.split(birdFly, 10, 9)[0];

        // Animations
        birdWalkAnim = new Animation<TextureRegion>(.1f, birdWalkFrames);
        birdPeckAnim = new Animation<TextureRegion>(.1f, birdPeckFrames);
        birdFlyAnim = new Animation<TextureRegion>(.1f, birdFlyFrames);
    }

Enums.java

Add a new EntityType of BIRD and add new EnityState to hold all of the possible states for the Bird Entity, we will add the state to the Entity class.

public enum EntityType {
  HERO,
  TREE,
  BIRD
}

public enum EnityState {
  NONE,
  IDLE,
  FEEDING,
  WALKING,
  FLYING,
  HOVERING,
  LANDING
}

Entity.java

There are several changes to the base Entity class, these are added to enable the Bird class to function but will be useful for other AI types. The shadow will need to be drawn so we add an extra draw call to the draw method, checking first if the shadow is present. A new tick method is added that accepts a chunk, some Entities will need to check the tiles in the current chunk.

// Class Vars
public Texture shadow;
public EnityState state; // For logic and selecting image to draw
public Boolean ticks; // .tick will only be called if true
public float time; // Store the time up for the Entity
public Vector3 destVec; // Destination vector for movement
public Tile currentTile; // Tile the Entity occupies
public float coolDown; // For logic

public void draw(SpriteBatch batch){
  if(shadow != null) batch.draw(shadow, pos.x, pos.y, width, height);
  if(texture != null) batch.draw(texture, pos.x, pos.y, width, height);
}

public void tick(float delta){
  time += delta;
}		    

public void tick(float delta, Chunk chunk){

}

Chunk.java

Later when we want to set the currentTile of the Bird we will use the Body position which is a Vector2, we will need to add a new method that returns a tile given a coordinate that has a X and a Y value. We can use the currentTile type to help us make logic decisions, for example we wouldn’t let a bird land on water.

public Tile getTile(Vector2 vector2) {
      ArrayList<Tile> chunk_row;
      int row = (int) ((vector2.y*tileSize/2) / numberRows);
      int col = (int) ((vector2.x*tileSize/2) / numberCols);
      if(tiles.size() > row && row >= 0){
          chunk_row = tiles.get(row);

          if(chunk_row != null && chunk_row.size() > col && col >= 0){
              return chunk_row.get(col);
          }
      }
      return null;
  }

Bird.java

I created this diagram after completing the code but this is where I should have started, when ever you design the logic behind an entity you will save yourself time by planning it out first. I used draw.io to produce this, its an online free and easy to use diagram tool.

bird_logic

It is also useful in my opinion to make the main method for your Entity as simple as possible and be readable, the complexity is hidden within methods. This becomes the following code:

Screen Shot 2017-09-05 at 01.40.08
Hovering Phase

Check if the Entity is hovering state there is a chance to change the state to LANDING, randomBoolean return true if a random value between 0 and 1 is less than the specified value. By setting the value to .2f there is a 20% chance the bird state changes, this is called each tick (60 times per second). This results in the hovering time being somewhat random.

private boolean isHovering(){
    return state == Enums.EnityState.HOVERING;
}
private void setLanding() {
    if(MathUtils.randomBoolean(.2f)){
        state = Enums.EnityState.LANDING;
    }
}

Landing Phase

Once set to LANDING the Entity will have the position Z value decreased until it is less than or equal to zero. On hitting zero the hit-boxes are made active and the state is set to NONE.

private boolean isLanding(){
    return state == Enums.EnityState.LANDING;
}

private void land() {
    if (isAirBorn()) pos.z -= 0.5;
    if(pos.z && <= 0){
        // Landed
        pos.z = 0;
        state = Enums.EnityState.NONE;
        toggleHitboxes(true);
    }
}

public boolean isAirBorn(){
 return pos.z > 0;
}

private void toggleHitboxes(boolean b) {
  body.setActive(b);
  sensor.setActive(b);
}

Move or Hover Phase

If the Entity is flying and has no destination then there is a 85% chance one is set, if the current tile occupied is water then a new destination is always set. Setting destination loops through all of the tiles and checks that the tile is grass, a random number 0 to 100 is equal to 100 and that the tile is not current one. When all these checks are true the destination tile is set and a new destination vector set. The destination vector was originally used to move the entity if you check the commits to this tutorial, it is now only used to check if the Entity is moving in a negative or positive direction to set if the texture is flipped or not.

The max height of the Entity is set to a value of 10 to 20 each time a new destination is set, this added some variety to its movement.

private boolean needsDestination() {
    return destVec == null && isFlying();
}

private void newDestinationOrHover(float delta, Chunk chunk) {
    // 85% chance a new destination is set, unless over water then always
    // get a new destination
    if(MathUtils.randomBoolean(.85f) || currentTile.isWater()){
        setDestination(delta, chunk);
        maxHeight = setHeight();
    } else {
        state = Enums.EnityState.HOVERING;
    }
}

private void setDestination(float delta, Chunk chunk){
    for(ArrayList<Tile> row : chunk.tiles){
        if(destTile != null) break;

        for(Tile tile : row){
            if (tile.isGrass() && MathUtils.random(100) > 99 && tile != currentTile){
                destTile = tile;
                getVector(destTile.pos);
                break;
            }
        }
    }
}

private float setHeight() {
    return MathUtils.random(10) + 10;
}

private boolean isFlying() {
 return state == Enums.EnityState.FLYING;
}

// Enitty.java
public void getVector(Vector3 dest){
    float dx = dest.x - pos.x;
    float dy = dest.y - pos.y;
    double h = Math.sqrt(dx * dx + dy * dy);
    float dn = (float)(h / 1.4142135623730951);

    destVec = new Vector3(dx / dn, dy / dn, 0);
}

Moving Phase

When the destination vector is not NULL (Could also check the destination tile) we move the Entity toward it until it has reached that position. We move the Body of the entity, as it is flying we assume it will not collide with anything so can move the body directly rather than applying for to it. After it moves we update the sensor and the vector3 position.

body.setTransform is used to move the entity, this accepts a Vector2 and an angle which will be 0. The new Vector2 is that of the body moving incrementally closer to the destination tile.

Using interpolate Interpolates between this vector and the given target vector by alpha (within range [0,1]) using the given Interpolation method. the result is stored in this vector.”  we can get the new Vector and move the body. There are many options for the movement type available check them out here: https://github.com/libgdx/libgdx/wiki/Interpolation

To check if the Entity is at the destination the current and destination tile positions. When within a distance of the destination the destination vector and tile are set to NULL.

private boolean hasDestination() {
    return destVec != null;
}

private void moveToDestination(float delta) {
    // https://github.com/libgdx/libgdx/wiki/Interpolation
    body.setTransform(body.getPosition().interpolate(new Vector2(destTile.pos.x + width, destTile.pos.y + height), delta * speed / 4, Interpolation.circle), 0);

    updatePositions();
}

private void updatePositions() {
    sensor.setTransform(body.getPosition(),0);
    pos.x = body.getPosition().x - width/2;
    pos.y = body.getPosition().y - height/4;
}

private void clearDestination() {
    if(isAtDestination()){
        destVec = null;
        destTile = null;
    }
}

private boolean isAtDestination() {
    return currentTile.pos.epsilonEquals(destTile.pos, 20);
}

Landed Phase

When the Z position of the Entity is zero we look to set a new state, this can be feeding, walking or flying. There is a 20% chance of flying, a 5% chance of feeding giving the flying was not set and a 3% change of walking given feeding state was not set. Most often after landing a bird will just fly away, some times it will feed and on the rarely it will walk. When walking is set than a one second cool down is set to ensure the walk lasts at least that amount of time.  Walking moves the bird in the direction it is currently set to face.

public boolean isNotAirBorn(){
   return pos.z == 0;
}

private void setNewState(float delta) {
    if(coolDown > 0){
        coolDown -= delta;
        if(isWalking()){
            walk(delta);
        }
    } else {
        if(MathUtils.randomBoolean(.2f)){
            state = Enums.EnityState.FLYING;
        } else if(MathUtils.randomBoolean(.5f)) {
            state = Enums.EnityState.FEEDING;
            coolDown = .5f;
        } else if(MathUtils.randomBoolean(.3f)) {
            state = Enums.EnityState.WALKING;
            coolDown = 1f;
        }
    }
} 

private boolean isWalking(){
    return state == Enums.EnityState.WALKING;
}

private void walk(float delta) {
  if(currentTile.isPassable()){
    if(tRegion.isFlipX()){
            body.setTransform(body.getPosition().x - speed / 4 * delta, body.getPosition().y,0);
        } else {
            body.setTransform(body.getPosition().x + speed / 4 * delta, body.getPosition().y,0);
        }
        updatePositions();
  }
}

gameclass.java

We add an instance to the gameclass on create() before calling the populateEntityMap, this will ensure the hit-boxes collisions are handled. The Update() method is updated to set the current tile of the entity, call the new tick method that accepts the delta time and also the Island current chunk. Add the same creation of the Bird Entity after if(control.reset){ to ensure a new bird is added when the Island is re-generated.

// New Class variable
// TIME
float time; 
...
// Create() Method
// Bird
island.entities.add(new Bird(new Vector3(10,10,0), box2D, Enums.EnityState.FLYING));

// HashMap of Entities for collisions
box2D.populateEntityMap(island.entities); 
...
// Update() Method
// Tick all entities
for(Entity e: island.entities){
    e.tick(Gdx.graphics.getDeltaTime());
    e.currentTile = island.chunk.getTile(e.body.getPosition());
    e.tick(Gdx.graphics.getDeltaTime(), island.chunk);
}

...
// Last line in Update() Method
time += Gdx.graphics.getDeltaTime();

We now have a Bird that will randomly fly around the Island, it is possible to collect the Bird by interacting with it.

bird_walking

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s