Kha Shmup Tutorial Part 7

Yesterday we coded up some collision detection so that the player could shoot enemies. Today we will be adding very basic animations to make that a little more satisfying.

Animation.hx

package;

import kha.Image;

class Animation {
  private var currentTime: Float;
  private var index: Int;

  public var frames: Array<Image>;
  public var frameDuration: Float;
  public var isDone(get, null): Bool;
  public var playState: PlayState;

  private function get_isDone(): Bool {
    return (Type.enumEq(playState, PlayState.Once) &&
            index >= frames.length);
  }

  public function new(frameDuration: Float, frames: Array<Image>, playState: PlayState) {
    this.playState = playState;
    this.frameDuration = frameDuration;
    this.frames = frames;
    index = 0;
  }

  public function reset(): Void {
    index = 0;
    currentTime = 0;
  }

  public function update(deltaTime: Float) {
    currentTime += deltaTime;

    if (currentTime >= frameDuration) {
      currentTime -= frameDuration;
      index += 1;
    }

    if (index >= frames.length) {
      if (Type.enumEq(playState, PlayState.Loop)) {
        index = 0;
      } else {
        index = frames.length;
      }
    }
  }

  public function getCurrentFrame(): Image {
    return frames[index];
  }

  public function getFrame(i: Int): Image {
    return frames[i];
  }
}

This is a very basic animation system that allows us to supply a frameDuration - which is the amount of time each frame takes, a series of images to represent each frame, and a state dictating whether or not we want to loop our animation or only play it once.

Everytime we call update we pass in the deltaTime. This gets added to the currentTime, and if its longer than the frameDuration we reset currentTime plus the difference, and then we increment the index used to retrieve the current frame. We also have some logic for whether or not we should loop the animation, and if it is done playing or not.

Now this implementation has a few issues. For one - we don’t have a way to really say where we want to orient each frame. This means if we have different sizes of images for the animation they snap to the top-left of the entity’s origin by default. We also don’t have a way to differentiate frame lengths.

All of this can be added in, but we do not need them.

Here is the PlayState that describes if we are looping an animation or not.

PlayState.hx

package;

enum PlayState {
  Loop;
  Once;
}

The reason I put this in its own file is that we reference it from other classes outside of Animation. I like to have a dedicated file for any class or enum that gets referenced by multiple classes, no matter how small, because it makes it easier for me to remember where to find definitions.

Now we have to update our Enemy to get the Animations to play:

Enemy.hx

package;

import kha.Image;
import kha.Sound;
import kha.audio1.Audio;
import kha.graphics2.Graphics;

enum EnemyState {
  Active;
  InActive;
  Exploding;
}

class Enemy implements Hitboxed {
  private var activeAnimation: Animation;
  private var explodeAnimation: Animation;
  private var explosionSound: Sound;
  private var enemyState: EnemyState;

  public var animation: Animation;
  public var hitbox: Hitbox;
  public var isActive(get, set): Bool;
  public var x: Int;
  public var y: Int;
  public var width(get, null): Int;
  public var height(get, null): Int;
  public var speed = 200.0;

  private function get_isActive(): Bool {
    return !Type.enumEq(enemyState, EnemyState.InActive);
  }

  private function set_isActive(value: Bool): Bool {
    if (value) {
      enemyState = EnemyState.Active;
    } else {
      enemyState = EnemyState.InActive;
    }

    return value;
  }

  private function get_width(): Int {
    return activeAnimation.getFrame(0).width;
  }

  private function get_height(): Int {
    return activeAnimation.getFrame(0).height;
  }

  public function new(x: Int, y: Int, activeAnimation: Animation, 
                      explodeAnimation: Animation, explosionSound: Sound) {
    this.activeAnimation = activeAnimation;
    this.explodeAnimation = explodeAnimation;
    this.explosionSound = explosionSound;
    hitbox = new Hitbox(x, y, 2, 0, activeAnimation.getFrame(0).width - 4, 
                        Std.int(activeAnimation.getFrame(0).height / 2));
    activate(x, y);
  }

  public function activate(x: Int, y: Int): Void {
    this.x = x;
    this.y = y;
    hitbox.updatePosition(x, y);
    enemyState = EnemyState.Active;
    setAnimation(activeAnimation);
    hitbox.enabled = true;
  }

  public function hit(): Void {
    Audio.play(explosionSound, true);
    enemyState = EnemyState.Exploding;
    setAnimation(explodeAnimation);
    hitbox.enabled = false;
  }

  public function render(g: Graphics): Void {
    if (!isActive) {
      return;
    }

    if (!animation.isDone) {
      g.drawImage(animation.getCurrentFrame(), x, y);
    }
  }

  public function update(deltaTime: Float): Void {
    if (!isActive) {
      return;
    }

    if (Type.enumEq(enemyState, EnemyState.Active)) {
      y += Math.round(speed * deltaTime);
      hitbox.updatePosition(x, y);
    }
    animation.update(deltaTime);

    if (Type.enumEq(enemyState, EnemyState.Exploding) &&animation.isDone) {
      isActive = false;
    }
  }

  private function setAnimation(newAnimation: Animation) {
    animation = newAnimation;
    animation.reset();
  }
}

So a few things have changed since we last saw this file. There’s no more image field - now there are two animations. We also no longer track isActive as explicit flag - now its based on whether or not the EnemyState enum is set to Active or not.

When updating an enemy we also update the animation, and if the enemy is exploding we make sure to set it to be inActive so the Enemy can be pooled once the animation is through.

When we hit the enemy we still play a sound, swap state to exploding, but we also set the hitbox’s enabled field to false, which prevents us from colliding with the enemy while they are exploding. This requires an update to Hitbox.hx

Hitbox.hx

package;

class Hitbox {
  public var enabled: Bool;
  public var rectangle(default, null): Rectangle;
  public var parentX: Int;
  public var parentY: Int;
  public var offsetX: Int;
  public var offsetY: Int;
  public var width: Int;
  public var height: Int;

  public function new(parentX: Int, parentY: Int, offsetX: Int, offsetY: Int, width: Int, height: Int) {
    this.parentX = parentX;
    this.parentY = parentY;
    this.offsetX = offsetX;
    this.offsetY = offsetY;
    this.width = width;
    this.height = height;
    enabled = true;

    rectangle = new Rectangle(parentX + offsetX, parentY + offsetY, width, height);
  }

  public function overlaps(other: Hitbox): Bool {
    if (!enabled || !other.enabled) {
      return false;
    }

    return rectangle.overlaps(other.rectangle);
  }

  public function updatePosition(parentX: Int, parentY: Int): Void {
    this.parentX = parentX;
    this.parentY = parentX;
    rectangle.x = parentX + offsetX;
    rectangle.y = parentY + offsetY;
    rectangle.width = width;
    rectangle.height = height;
  }

}

Whenever a hitbox is not enabled OR the other hitbox, we do not count the collision. We have one more big change, which is in the EnemySpawner:

EnemySpawner.hx

package;

import kha.Assets;
import kha.Image;
import kha.Sound;
import kha.graphics2.Graphics;

class EnemySpawner {
  private var enemyImage: Image;
  private var activeAnimation: Animation;
  private var explodeAnimation: Animation;
  private var explodeSound: Sound;
  private var minSpawnX: Int;
  private var maxSpawnX: Int;
  private var maxPositionY: Int;
  private var spawnMinTime: Float;
  private var spawnMaxTime: Float;
  private var spawnNextTime: Float;
  private var currentTime: Float;
  private var enemies: Array<Enemy>;

  public function new(spawnMinTime: Float, spawnMaxTime: Float, 
                      ?minSpawnX: Int = 0, maxSpawnX: Int = 0, maxPositionY: Int = 600) {
    setAssets();
    this.spawnMinTime = spawnMinTime;
    this.spawnMaxTime = spawnMaxTime;
    this.minSpawnX = minSpawnX;
    this.maxSpawnX = maxSpawnX;
    this.maxPositionY = maxPositionY;
    currentTime = 0;
    enemies = new Array<Enemy>();
    spawnNextTime = generateRandomSpawnTime(spawnMinTime, spawnMaxTime);
  }

  private function generateRandomSpawnTime(spawnMinTime: Float, spawnMaxTime: Float): Float {
    return spawnMinTime + Math.random() * (spawnMaxTime - spawnMinTime);
  }

  private function generateRandomX(minSpawnX, maxSpawnX): Int {
    return Std.random(maxSpawnX - enemyImage.width) + minSpawnX;
  }

  private function setAssets() {
    enemyImage = Assets.images.enemyShip;
    var explodeFrames = new Array<Image>();
    explodeFrames.push(Assets.images.smokeOrange0);
    explodeFrames.push(Assets.images.smokeOrange1);
    explodeFrames.push(Assets.images.smokeOrange2);
    explodeFrames.push(Assets.images.smokeOrange3);
    activeAnimation = new Animation(1, [enemyImage], PlayState.Loop);
    explodeAnimation = new Animation(0.1, explodeFrames, PlayState.Once);
    explodeSound = Assets.sounds.enemyExplosion;
  }

  private function spawn(x: Int, y: Int): Void {
      for (i in 0...enemies.length) {
        if (!enemies[i].isActive) {
          enemies[i].activate(x, y);
          return;
        }
      }

    enemies.push(new Enemy(x, y, activeAnimation, explodeAnimation, explodeSound));
  }

  public function getActiveEnemies(): Array<Enemy> {
    var actives = new Array<Enemy>();

    for (i in 0...enemies.length) {
      if (enemies[i].isActive) {
        actives.push(enemies[i]);
      }
    }
    return actives;
  }

  public function update(deltaTime: Float) {
    currentTime += deltaTime;

    if (currentTime >= spawnNextTime) {
      currentTime = 0;
      spawnNextTime = generateRandomSpawnTime(spawnMinTime, spawnMaxTime);
      spawn(generateRandomX(minSpawnX, maxSpawnX), -enemyImage.height);
    }

    for (i in 0...enemies.length) {
      var enemy = enemies[i];
      enemy.update(deltaTime);

      if (enemy.isActive && enemy.y > maxPositionY) {
        enemy.isActive = false;
      }
    }
  }

  public function render(g: Graphics) {
    for (i in 0...enemies.length) {
      enemies[i].render(g);
    }
  }
}

The major changes lie in the creation of the animation - we call a method to set our assets, and from there we load up the images and construct the animation objects.

I have added four explosion sprites to the Assets folder. These are all one image, just scaled down. We could have just progressively scaled this image down instead of setting up animations like this - but I wanted to elaborate how we would draw keyframes in a game.

Explosion 0 Explosion 1 Explosion 2 Explosion 3

Also remember the whole lack of orientation regarding frames I mentioned above? That means each image, even though the sprite gets smaller and smaller had to be the same size and the image had to pasted in the middle. A good “next step” task would be to implement a Frame data type that could wrap each image and specify an offset from the parent’s position.

Running the Application

You should now see the enemies disappearing into a cloud of smoke when you shoot them

Explosion

Check my part 7 branch if this does not work for you: https://github.com/jamiltron/KhaShmup/tree/part-7

In part 8 we will start displaying score for the player, counting the number of enemies defeated.